From e86ec3901dd94ecfefccac0d82cadc7e65a789b2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 1 May 2026 12:34:42 +0200 Subject: [PATCH 001/180] trigger build --- biome.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index fa4cffb..69803f6 100644 --- a/biome.json +++ b/biome.json @@ -7,8 +7,8 @@ "!!**/build", "!!**/node_modules", "!!**/coverage", - "!!**/artifacts", - "!!**/cache" + "!!**/cache", + "!!**/artifacts" ] }, "overrides": [ From 50ade9fda5b064aa44933a338d3035d23314518d Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 1 May 2026 16:34:12 +0200 Subject: [PATCH 002/180] feat(vault): add depositForPermit for gasless approve+deposit Adds an ERC-2612 permit entry point to CollateralVault so callers can deposit on behalf of any recipient in a single tx without a prior approve, mirroring the pattern previously living on HashPowerPerpsDEX. Also opens up depositFor to the public so non-authorized callers can fund recipients. Covered by 5 new tests: happy path, distinct recipient, expired deadline, signature/sender mismatch, and replay protection. --- contracts/contracts/CollateralVault.sol | 16 +- contracts/tests/collateralVault.test.ts | 192 ++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/CollateralVault.sol b/contracts/contracts/CollateralVault.sol index 74d8885..ee93541 100644 --- a/contracts/contracts/CollateralVault.sol +++ b/contracts/contracts/CollateralVault.sol @@ -6,6 +6,7 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Versionable} from "./interfaces/Versionable.sol"; import {ICollateralVault} from "./interfaces/ICollateralVault.sol"; @@ -129,13 +130,26 @@ contract CollateralVault is ICollateralVault, UUPSUpgradeable, OwnableUpgradeabl _depositFor(_msgSender(), _msgSender(), amount); } + /// @notice Deposit collateral tokens using an ERC-2612 permit (approve + deposit in one tx). + /// @param amount Amount of collateral to deposit. + /// @param deadline Permit signature deadline. + /// @param v Permit signature v. + /// @param r Permit signature r. + /// @param s Permit signature s. + function depositForPermit(address recipient, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + { + IERC20Permit(address(collateralToken)).permit(_msgSender(), address(this), amount, deadline, v, r, s); + _depositFor(_msgSender(), recipient, amount); + } + /// @notice Withdraw collateral tokens; burns receipt tokens. /// Reverts if the withdrawal would breach portfolio margin requirements. function withdraw(uint256 amount) external { _withdrawTo(_msgSender(), _msgSender(), amount); } - function depositFor(address recipient, uint256 amount) external onlyAuthorized { + function depositFor(address recipient, uint256 amount) external { _depositFor(_msgSender(), recipient, amount); } diff --git a/contracts/tests/collateralVault.test.ts b/contracts/tests/collateralVault.test.ts index 8dd25fe..5fa400a 100644 --- a/contracts/tests/collateralVault.test.ts +++ b/contracts/tests/collateralVault.test.ts @@ -428,6 +428,198 @@ describe("CollateralVault", () => { }); }); + // ── depositForPermit ──────────────────────────────────────────────────── + + describe("depositForPermit", () => { + const PERMIT_TYPES = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + } as const; + + type VaultFixture = Awaited>; + type Wallet = VaultFixture["alice"]; + type Usdc = VaultFixture["usdc"]; + + /** Build an ERC-2612 permit signature for {owner=signer | override} → spender. */ + async function buildPermitSig(opts: { + signer: Wallet; + usdc: Usdc; + spender: `0x${string}`; + value: bigint; + deadline: bigint; + /** Override the `owner` field in the typed message (for invalid-signer tests). */ + owner?: `0x${string}`; + }) { + const owner = opts.owner ?? opts.signer.account.address; + const [, name, version, chainId, verifyingContract] = await opts.usdc.read.eip712Domain(); + const nonce = await opts.usdc.read.nonces([owner]); + const sig = await opts.signer.signTypedData({ + account: opts.signer.account, + domain: { name, version, chainId, verifyingContract }, + types: PERMIT_TYPES, + primaryType: "Permit", + message: { + owner, + spender: opts.spender, + value: opts.value, + nonce, + deadline: opts.deadline, + }, + }); + return { + r: `0x${sig.slice(2, 66)}` as `0x${string}`, + s: `0x${sig.slice(66, 130)}` as `0x${string}`, + v: Number.parseInt(sig.slice(130, 132), 16), + }; + } + + it("permits and deposits in a single tx without prior allowance", async () => { + const { vault, usdc, owner } = await networkHelpers.loadFixture(deployVaultFixture); + // Use a wallet that has NOT approved the vault, to prove the permit path is the only + // thing setting allowance. + const wallets = await viem.getWalletClients(); + const fresh = wallets[4]; + const amount = 5_000_000n; + await usdc.write.transfer([fresh.account.address, amount], { account: owner.account }); + assert.equal(await usdc.read.allowance([fresh.account.address, vault.address]), 0n); + + const latest = await networkHelpers.time.latest(); + const deadline = BigInt(latest + 600); + const { v, r, s } = await buildPermitSig({ + signer: fresh, + usdc, + spender: vault.address, + value: amount, + deadline, + }); + + await viem.assertions.emitWithArgs( + vault.write.depositForPermit([fresh.account.address, amount, deadline, v, r, s], { + account: fresh.account, + }), + vault, + "Deposited", + [getAddress(fresh.account.address), amount, getAddress(fresh.account.address)], + ); + + assert.equal(await vault.read.balanceOf([fresh.account.address]), amount); + assert.equal(await usdc.read.balanceOf([fresh.account.address]), 0n); + // Permit consumed the entire allowance — none left over for replay. + assert.equal(await usdc.read.allowance([fresh.account.address, vault.address]), 0n); + assert.equal(await usdc.read.nonces([fresh.account.address]), 1n); + }); + + it("can mint receipt tokens to a different recipient than the signer", async () => { + const { vault, usdc, alice, bob } = await networkHelpers.loadFixture(deployVaultFixture); + const amount = 2_000_000n; + const latest = await networkHelpers.time.latest(); + const deadline = BigInt(latest + 600); + + const { v, r, s } = await buildPermitSig({ + signer: alice, + usdc, + spender: vault.address, + value: amount, + deadline, + }); + + await viem.assertions.emitWithArgs( + vault.write.depositForPermit([bob.account.address, amount, deadline, v, r, s], { + account: alice.account, + }), + vault, + "Deposited", + [getAddress(bob.account.address), amount, getAddress(alice.account.address)], + ); + + assert.equal(await vault.read.balanceOf([bob.account.address]), amount); + assert.equal(await vault.read.balanceOf([alice.account.address]), 0n); + }); + + it("reverts on expired deadline", async () => { + const { vault, usdc, alice } = await networkHelpers.loadFixture(deployVaultFixture); + const amount = 1_000_000n; + const latest = await networkHelpers.time.latest(); + const deadline = BigInt(latest - 1); + + const { v, r, s } = await buildPermitSig({ + signer: alice, + usdc, + spender: vault.address, + value: amount, + deadline, + }); + + await viem.assertions.revertWithCustomError( + vault.write.depositForPermit([alice.account.address, amount, deadline, v, r, s], { + account: alice.account, + }), + usdc, + "ERC2612ExpiredSignature", + ); + }); + + it("reverts when the signature was not produced by msg.sender", async () => { + const { vault, usdc, alice, bob } = await networkHelpers.loadFixture(deployVaultFixture); + const amount = 1_000_000n; + const latest = await networkHelpers.time.latest(); + const deadline = BigInt(latest + 600); + + // Bob signs a permit message that claims owner = alice. The contract calls + // permit(msg.sender = alice, …) so the recovered signer (bob) won't match owner (alice). + const { v, r, s } = await buildPermitSig({ + signer: bob, + usdc, + spender: vault.address, + value: amount, + deadline, + owner: alice.account.address, + }); + + await viem.assertions.revertWithCustomError( + vault.write.depositForPermit([alice.account.address, amount, deadline, v, r, s], { + account: alice.account, + }), + usdc, + "ERC2612InvalidSigner", + ); + }); + + it("reverts on signature replay (nonce already consumed)", async () => { + const { vault, usdc, alice } = await networkHelpers.loadFixture(deployVaultFixture); + const amount = 1_000_000n; + const latest = await networkHelpers.time.latest(); + const deadline = BigInt(latest + 600); + + const { v, r, s } = await buildPermitSig({ + signer: alice, + usdc, + spender: vault.address, + value: amount, + deadline, + }); + + await vault.write.depositForPermit( + [alice.account.address, amount, deadline, v, r, s], + { account: alice.account }, + ); + + // Same signature can't be reused: nonce was bumped, so the recovered signer mismatches. + await viem.assertions.revertWithCustomError( + vault.write.depositForPermit([alice.account.address, amount, deadline, v, r, s], { + account: alice.account, + }), + usdc, + "ERC2612InvalidSigner", + ); + }); + }); + // ── totalSupply tracks deposits ───────────────────────────────────────── describe("totalSupply", () => { From 8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 1 May 2026 18:33:06 +0200 Subject: [PATCH 003/180] feat(indexer): add CollateralVault subgraph with Goldsky CI/CD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone subgraph that indexes the CollateralVault contract: deposits, withdrawals, the insurance fund, and internal transfers (bucketed into PERPS / OPTIONS / OTHER via dataSource.context()). CI: indexer-tests.yml runs codegen/build/matchstick on every PR; deploy-subgraph.yml builds, pushes to Goldsky, rolls a per-env tag (dev-latest / stg-latest / lmn-latest), verifies the deployment, and notifies Slack — modeled on hashprice-oracle's deploy-subgraph workflow and reusing its gen-tag / slack-notify composite actions. --- .github/actions/gen-tag/action.yml | 147 + .github/actions/slack-notify/action.yml | 213 ++ .github/workflows/deploy-subgraph.yml | 471 +++ .github/workflows/indexer-tests.yml | 79 + indexer/.env.example | 13 + indexer/.gitignore | 11 + indexer/README.md | 217 ++ indexer/assembly.d.ts | 9 + indexer/docker-compose.yml | 50 + indexer/package.json | 34 + indexer/pnpm-lock.yaml | 3578 +++++++++++++++++++++++ indexer/schema.graphql | 125 + indexer/src/ids.ts | 6 + indexer/src/vault.ts | 350 +++ indexer/subgraph.template.yaml | 51 + indexer/tests/deposit.test.ts | 115 + indexer/tests/helpers.ts | 90 + indexer/tests/insurance-fund.test.ts | 57 + indexer/tests/transfer.test.ts | 141 + indexer/tests/withdraw.test.ts | 108 + indexer/tsconfig.json | 23 + indexer/types/ambient.d.ts | 9 + 22 files changed, 5897 insertions(+) create mode 100644 .github/actions/gen-tag/action.yml create mode 100644 .github/actions/slack-notify/action.yml create mode 100644 .github/workflows/deploy-subgraph.yml create mode 100644 .github/workflows/indexer-tests.yml create mode 100644 indexer/.env.example create mode 100644 indexer/.gitignore create mode 100644 indexer/README.md create mode 100644 indexer/assembly.d.ts create mode 100644 indexer/docker-compose.yml create mode 100644 indexer/package.json create mode 100644 indexer/pnpm-lock.yaml create mode 100644 indexer/schema.graphql create mode 100644 indexer/src/ids.ts create mode 100644 indexer/src/vault.ts create mode 100644 indexer/subgraph.template.yaml create mode 100644 indexer/tests/deposit.test.ts create mode 100644 indexer/tests/helpers.ts create mode 100644 indexer/tests/insurance-fund.test.ts create mode 100644 indexer/tests/transfer.test.ts create mode 100644 indexer/tests/withdraw.test.ts create mode 100644 indexer/tsconfig.json create mode 100644 indexer/types/ambient.d.ts diff --git a/.github/actions/gen-tag/action.yml b/.github/actions/gen-tag/action.yml new file mode 100644 index 0000000..c9fd7d2 --- /dev/null +++ b/.github/actions/gen-tag/action.yml @@ -0,0 +1,147 @@ +name: 'Generate Version Tag' +description: 'Generates component-based version tags for monorepo deployments' + +inputs: + component: + description: 'Component name (folder name, e.g., indexer, ui, oracle-update)' + required: true + major_version: + description: 'Target major version (triggers major bump if greater than current)' + required: false + default: '3' + environment_override: + description: 'Override environment (for workflow_dispatch). Leave empty for branch-based detection.' + required: false + default: '' + +outputs: + tag_name: + description: 'Full tag name (e.g., indexer-v2.0.5-dev)' + value: ${{ steps.gen_tag.outputs.tag_name }} + vtag: + description: 'Version tag (same as tag_name)' + value: ${{ steps.gen_tag.outputs.vtag }} + version: + description: 'Version string with v prefix (e.g., v2.0.5-dev)' + value: ${{ steps.gen_tag.outputs.version }} + vfull: + description: 'Semantic version without prefix (e.g., 2.0.5)' + value: ${{ steps.gen_tag.outputs.vfull }} + environment: + description: 'Deployment environment (dev, stg, main)' + value: ${{ steps.determine_env.outputs.environment }} + should_create_release: + description: 'Whether to create a GitHub release (true for main)' + value: ${{ steps.determine_env.outputs.should_create_release }} + is_cicd_branch: + description: 'Whether this is a CI/CD test branch' + value: ${{ steps.determine_env.outputs.is_cicd_branch }} + +runs: + using: 'composite' + steps: + - name: Determine environment + id: determine_env + shell: bash + run: | + ENV_OVERRIDE="${{ inputs.environment_override }}" + IS_CICD="false" + + if [ -n "$ENV_OVERRIDE" ]; then + # Use override from workflow_dispatch + ENV="$ENV_OVERRIDE" + CREATE_RELEASE="false" + [ "$ENV" == "main" ] && CREATE_RELEASE="true" + else + # Detect from branch name + BRANCH="${{ github.ref_name }}" + if [ "$BRANCH" == "main" ]; then + ENV="main" + CREATE_RELEASE="true" + elif [ "$BRANCH" == "stg" ]; then + ENV="stg" + CREATE_RELEASE="false" + elif [[ "$BRANCH" == cicd/* ]]; then + ENV="dev" + CREATE_RELEASE="false" + IS_CICD="true" + echo "🔧 CI/CD test branch detected - will skip build/deploy" + else + ENV="dev" + CREATE_RELEASE="false" + fi + fi + echo "environment=${ENV}" >> $GITHUB_OUTPUT + echo "should_create_release=${CREATE_RELEASE}" >> $GITHUB_OUTPUT + echo "is_cicd_branch=${IS_CICD}" >> $GITHUB_OUTPUT + + - name: Generate version tag + id: gen_tag + shell: bash + run: | + # Component-based versioning for monorepo + # Pattern: -v..[-env] + COMPONENT="${{ inputs.component }}" + VMAJ_NEW=${{ inputs.major_version }} + VMIN_NEW=0 + VPAT_NEW=0 + + set +o pipefail + + # Find the last production tag for this component (without -dev/-stg suffix) + # Use git tag -l with sort to find latest, not git describe (which requires reachability) + VLAST=$(git tag -l "${COMPONENT}-v[0-9]*" | grep -E "^${COMPONENT}-v[0-9]+\.[0-9]+\.[0-9]+$" | sort -V | tail -n 1 | sed "s/${COMPONENT}-v//" || echo "") + + if [ -n "$VLAST" ]; then + # Parse existing version + eval $(echo "$VLAST" | awk -F '.' '{print "VMAJ="$1" VMIN="$2" VPAT="$3}') + else + # No existing tags - start fresh + VMAJ=$VMAJ_NEW + VMIN=0 + VPAT=0 + fi + + ENV="${{ steps.determine_env.outputs.environment }}" + + if [ "$ENV" = "main" ]; then + # Production release - increment version + if [ "$VMAJ_NEW" -gt "$VMAJ" ]; then + # Major version bump requested + VMAJ=$VMAJ_NEW + VMIN=$VMIN_NEW + VPAT=$VPAT_NEW + else + # Increment minor version, reset patch + VMIN=$((VMIN+1)) + VPAT=0 + fi + VFULL=${VMAJ}.${VMIN}.${VPAT} + VTAG=${COMPONENT}-v${VFULL} + VERSION="v${VFULL}" + else + # Non-production - use commit count as patch for uniqueness + MB=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null || git rev-parse HEAD) + VPAT=$(git rev-list --count --no-merges ${MB}..HEAD 2>/dev/null || echo "0") + VFULL=${VMAJ}.${VMIN}.${VPAT} + RNAME=${GITHUB_REF_NAME##*/} + [ "$GITHUB_EVENT_NAME" = "pull_request" ] && RNAME=pr${GITHUB_REF_NAME%/merge} + VTAG=${COMPONENT}-v${VFULL}-${RNAME} + VERSION="v${VFULL}-${RNAME}" + fi + + # Output variables + echo "tag_name=${VTAG}" >> $GITHUB_OUTPUT + echo "vtag=${VTAG}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "vfull=${VFULL}" >> $GITHUB_OUTPUT + + # Summary + echo "📦 Component: ${COMPONENT}" >> $GITHUB_STEP_SUMMARY + echo "🌍 Environment: ${ENV}" >> $GITHUB_STEP_SUMMARY + echo "✅ Proposed Tag: ${VTAG} (will be created after successful deployment)" >> $GITHUB_STEP_SUMMARY + if [ -n "$VLAST" ]; then + echo "📋 Last Production Tag: ${COMPONENT}-v${VLAST}" >> $GITHUB_STEP_SUMMARY + else + echo "📋 Last Production Tag: (none - first deployment)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/actions/slack-notify/action.yml b/.github/actions/slack-notify/action.yml new file mode 100644 index 0000000..67ffee9 --- /dev/null +++ b/.github/actions/slack-notify/action.yml @@ -0,0 +1,213 @@ +name: 'Slack Deployment Notification' +description: 'Send beautifully formatted deployment notifications to Slack' + +inputs: + status: + description: 'Deployment status (success, failure, cancelled)' + required: true + environment: + description: 'Target environment (dev, stg, main)' + required: true + service_name: + description: 'Name of the service being deployed' + required: true + version: + description: 'Version tag being deployed' + required: true + slack_webhook_url: + description: 'Slack webhook URL' + required: true + github_token: + description: 'GitHub token for API calls to fetch PR info' + required: false + default: '' + additional_info: + description: 'Additional info to include in markdown format (optional)' + required: false + default: '' + image_tag: + description: 'Docker image tag if applicable (optional)' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Get PR info + id: pr_info + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SHA: ${{ github.sha }} + run: | + # Try to find PR number from merge commit message first + COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null || echo "") + + # Pattern: "Merge pull request #123 from ..." + if [[ "$COMMIT_MSG" =~ Merge\ pull\ request\ \#([0-9]+) ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "📎 Found PR #$PR_NUMBER from merge commit" + # Pattern: "... (#123)" - squash merge pattern + elif [[ "$COMMIT_MSG" =~ \(\#([0-9]+)\) ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "📎 Found PR #$PR_NUMBER from squash commit" + # Fallback: Use GitHub API to find associated PR + elif [ -n "$GH_TOKEN" ]; then + PR_NUMBER=$(curl -s -H "Authorization: token $GH_TOKEN" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" \ + | jq -r '.[0].number // empty' 2>/dev/null || echo "") + if [ -n "$PR_NUMBER" ]; then + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "📎 Found PR #$PR_NUMBER from GitHub API" + else + echo "pr_number=" >> $GITHUB_OUTPUT + echo "📎 No PR found for this commit" + fi + else + echo "pr_number=" >> $GITHUB_OUTPUT + echo "📎 No PR info available (no token provided)" + fi + + - name: Send Slack notification + shell: bash + env: + SLACK_WEBHOOK: ${{ inputs.slack_webhook_url }} + STATUS: ${{ inputs.status }} + ENVIRONMENT: ${{ inputs.environment }} + SERVICE_NAME: ${{ inputs.service_name }} + VERSION: ${{ inputs.version }} + ADDITIONAL_INFO: ${{ inputs.additional_info }} + IMAGE_TAG: ${{ inputs.image_tag }} + PR_NUMBER: ${{ steps.pr_info.outputs.pr_number }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: | + # Set environment emoji and label + case "$ENVIRONMENT" in + dev) ENV_EMOJI="🔧"; ENV_LABEL="DEV" ;; + stg) ENV_EMOJI="🧪"; ENV_LABEL="STG" ;; + main) ENV_EMOJI="🚀"; ENV_LABEL="PROD" ;; + infra|INFRA) ENV_EMOJI="🏗️"; ENV_LABEL="INFRA" ;; + *) ENV_EMOJI="📦"; ENV_LABEL="${ENVIRONMENT^^}" ;; + esac + + # Set status emoji and color + case "$STATUS" in + success) STATUS_EMOJI="✅"; COLOR="good"; STATUS_TEXT="Deployed Successfully" ;; + failure) STATUS_EMOJI="❌"; COLOR="danger"; STATUS_TEXT="Deployment Failed" ;; + cancelled) STATUS_EMOJI="⚠️"; COLOR="warning"; STATUS_TEXT="Deployment Cancelled" ;; + skipped) STATUS_EMOJI="⏭️"; COLOR="#808080"; STATUS_TEXT="Skipped" ;; + updated) STATUS_EMOJI="📝"; COLOR="#4A90D9"; STATUS_TEXT="Code Updated" ;; + *) STATUS_EMOJI="ℹ️"; COLOR="#808080"; STATUS_TEXT="$STATUS" ;; + esac + + # Get commit info + COMMIT_SHORT="${GITHUB_SHA:0:7}" + COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null | head -n 1 | cut -c1-80 || echo "No commit message") + + # Clean up merge commit messages + if [[ "$COMMIT_MSG" =~ ^Merge\ pull\ request\ \#[0-9]+\ from\ .*/(.+)$ ]]; then + COMMIT_MSG="Merged: ${BASH_REMATCH[1]}" + fi + + # Build URLs + REPO_SHORT="${GITHUB_REPOSITORY#*/}" + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + COMMIT_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + + # Build header: ENV | repo | service | status + HEADER_TEXT="$ENV_EMOJI $ENV_LABEL | $REPO_SHORT | $SERVICE_NAME | $STATUS_EMOJI $STATUS_TEXT" + + # Build fields array + FIELDS=$(jq -n \ + --arg version "$VERSION" \ + --arg actor "$GITHUB_ACTOR" \ + '[ + {"type": "mrkdwn", "text": ("*Version:*\n`" + $version + "`")}, + {"type": "mrkdwn", "text": ("*Triggered by:*\n" + $actor)} + ]') + + # Add PR field if available + if [ -n "$PR_NUMBER" ]; then + PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + FIELDS=$(echo "$FIELDS" | jq --arg pr_url "$PR_URL" --arg pr_num "$PR_NUMBER" \ + '. + [{"type": "mrkdwn", "text": ("*Pull Request:*\n<" + $pr_url + "|#" + $pr_num + ">")}]') + fi + + # Add image tag field if available + if [ -n "$IMAGE_TAG" ]; then + FIELDS=$(echo "$FIELDS" | jq --arg img "$IMAGE_TAG" \ + '. + [{"type": "mrkdwn", "text": ("*Image:*\n`" + $img + "`")}]') + fi + + # Build commit section + COMMIT_TEXT="*Commit:* ${COMMIT_MSG}" + + # Build blocks array + BLOCKS=$(jq -n \ + --arg header "$HEADER_TEXT" \ + --argjson fields "$FIELDS" \ + --arg commit_text "$COMMIT_TEXT" \ + '[ + {"type": "header", "text": {"type": "plain_text", "text": $header, "emoji": true}}, + {"type": "section", "fields": $fields}, + {"type": "section", "text": {"type": "mrkdwn", "text": $commit_text}} + ]') + + # Add additional info block if provided + if [ -n "$ADDITIONAL_INFO" ]; then + BLOCKS=$(echo "$BLOCKS" | jq --arg info "$ADDITIONAL_INFO" \ + '. + [{"type": "section", "text": {"type": "mrkdwn", "text": $info}}]') + fi + + # Add action buttons + if [ -n "$PR_NUMBER" ]; then + PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + BLOCKS=$(echo "$BLOCKS" | jq \ + --arg pr_url "$PR_URL" \ + --arg pr_num "$PR_NUMBER" \ + --arg run_url "$RUN_URL" \ + --arg commit_url "$COMMIT_URL" \ + '. + [ + {"type": "actions", "elements": [ + {"type": "button", "text": {"type": "plain_text", "text": "🔀 View PR", "emoji": true}, "url": $pr_url, "style": "primary"}, + {"type": "button", "text": {"type": "plain_text", "text": "📋 Actions", "emoji": true}, "url": $run_url}, + {"type": "button", "text": {"type": "plain_text", "text": "🔍 Commit", "emoji": true}, "url": $commit_url} + ]} + ]') + else + BLOCKS=$(echo "$BLOCKS" | jq \ + --arg run_url "$RUN_URL" \ + --arg commit_url "$COMMIT_URL" \ + '. + [ + {"type": "actions", "elements": [ + {"type": "button", "text": {"type": "plain_text", "text": "📋 Actions", "emoji": true}, "url": $run_url, "style": "primary"}, + {"type": "button", "text": {"type": "plain_text", "text": "🔍 Commit", "emoji": true}, "url": $commit_url} + ]} + ]') + fi + + # Build final payload + PAYLOAD=$(jq -n \ + --arg color "$COLOR" \ + --argjson blocks "$BLOCKS" \ + '{"attachments": [{"color": $color, "blocks": $blocks}]}') + + # Send to Slack + RESPONSE=$(curl -s -X POST -H 'Content-type: application/json' -d "$PAYLOAD" "$SLACK_WEBHOOK") + + if [ "$RESPONSE" = "ok" ]; then + echo "📢 Slack notification sent successfully" + else + echo "⚠️ Slack response: $RESPONSE" + echo "Payload was:" + echo "$PAYLOAD" | jq . + fi + diff --git a/.github/workflows/deploy-subgraph.yml b/.github/workflows/deploy-subgraph.yml new file mode 100644 index 0000000..f507403 --- /dev/null +++ b/.github/workflows/deploy-subgraph.yml @@ -0,0 +1,471 @@ +name: Deploy collateral-vault subgraph + +on: + push: + branches: + - dev + - stg + - main + - "cicd/**" + paths: + - "indexer/**" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/deploy-subgraph.yml" + pull_request: + branches: + - dev + - stg + - main + paths: + - "indexer/**" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/deploy-subgraph.yml" + workflow_dispatch: + inputs: + environment: + description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - stg + - main + +concurrency: + group: ci-vault-subgraph-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +permissions: + id-token: write # Required for OIDC + contents: write # Required for creating git tags + +env: + SERVICE_NAME: collateral-vault-subgraph + +jobs: + setup: + name: 🔧 Setup + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.gen_tag.outputs.environment }} + is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} + version: ${{ steps.gen_tag.outputs.version }} + tag: ${{ steps.gen_tag.outputs.tag_name }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Generate version tag + id: gen_tag + uses: ./.github/actions/gen-tag + with: + component: indexer + major_version: "1" + environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + + build: + name: 🔨 Build + runs-on: ubuntu-latest + needs: setup + environment: ${{ github.event_name != 'pull_request' && needs.setup.outputs.environment || '' }} + outputs: + version: ${{ needs.setup.outputs.version }} + tag: ${{ needs.setup.outputs.tag }} + environment: ${{ needs.setup.outputs.environment }} + goldsky_subgraph_name: ${{ steps.env.outputs.goldsky_subgraph_name }} + goldsky_rolling_tag: ${{ steps.env.outputs.goldsky_rolling_tag }} + goldsky_endpoint: ${{ steps.env.outputs.goldsky_endpoint }} + is_cicd_branch: ${{ needs.setup.outputs.is_cicd_branch }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: indexer/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: indexer/pnpm-lock.yaml + + - name: Set environment outputs + id: env + run: | + ENV="${{ needs.setup.outputs.environment }}" + + # Goldsky subgraph name — override via vars.GOLDSKY_SUBGRAPH_NAME (default: collateral-vault) + GS_NAME="${{ vars.GOLDSKY_SUBGRAPH_NAME }}" + if [ -z "$GS_NAME" ]; then + GS_NAME="collateral-vault" + fi + echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT + + # Rolling tag and public endpoint per environment + case $ENV in + dev) + echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.DEV_GS_VAULT }}" >> $GITHUB_OUTPUT + ;; + stg) + echo "goldsky_rolling_tag=stg-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.STG_GS_VAULT }}" >> $GITHUB_OUTPUT + ;; + main) + echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.LMN_GS_VAULT }}" >> $GITHUB_OUTPUT + ;; + esac + + echo "🎯 Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" + + - name: Install dependencies + working-directory: ./indexer + run: pnpm install --frozen-lockfile + + - name: Prepare subgraph configuration + working-directory: ./indexer + env: + NETWORK: ${{ vars.NETWORK }} + VAULT_ADDRESS: ${{ vars.VAULT_ADDRESS }} + VAULT_START_BLOCK: ${{ vars.VAULT_START_BLOCK }} + PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} + OPTIONS_ADDRESS: ${{ vars.OPTIONS_ADDRESS }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "ℹ️ PR build — using .env.example dummy values" + set -a && source .env.example && set +a + fi + echo "⚙️ Preparing subgraph for ${{ needs.setup.outputs.environment }}..." + echo " Network: ${NETWORK}" + echo " Vault: ${VAULT_ADDRESS} (block ${VAULT_START_BLOCK})" + echo " Perps: ${PERPS_ADDRESS}" + echo " Options: ${OPTIONS_ADDRESS}" + pnpm prepare:env + echo "✅ Configuration ready" + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Generate code, build, test + working-directory: ./indexer + run: | + pnpm codegen + pnpm build + pnpm test + echo "✅ Build complete" + + - name: Upload build artifacts + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: subgraph-build + path: | + indexer/build/ + indexer/generated/ + indexer/src/ + indexer/subgraph.yaml + indexer/schema.graphql + contracts/abi/ + retention-days: 1 + + - name: Build summary + run: | + echo "## 🔨 Build Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${{ needs.setup.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Subgraph | \`${{ steps.env.outputs.goldsky_subgraph_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.env.outputs.goldsky_rolling_tag }}\` |" >> $GITHUB_STEP_SUMMARY + + - name: PR build summary + if: github.event_name == 'pull_request' + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** Build only (no deployment) — deploy will run on merge" >> $GITHUB_STEP_SUMMARY + + deploy: + name: 🚀 Deploy to Goldsky + runs-on: ubuntu-latest + needs: build + if: github.event_name != 'pull_request' + outputs: + deploy_status: ${{ steps.goldsky_deploy.outputs.deploy_status }} + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: subgraph-build + path: . + + - name: Install Goldsky CLI + run: curl -fsSL https://goldsky.com | sh -s -- -f + + - name: Deploy and tag + id: goldsky_deploy + working-directory: ./indexer + env: + GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || needs.build.outputs.environment == 'stg' && secrets.STG_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} + GOLDSKY_SUBGRAPH_NAME: ${{ needs.build.outputs.goldsky_subgraph_name }} + SUBGRAPH_VERSION: ${{ needs.build.outputs.version }} + GOLDSKY_ROLLING_TAG: ${{ needs.build.outputs.goldsky_rolling_tag }} + GOLDSKY_ENDPOINT: ${{ needs.build.outputs.goldsky_endpoint }} + run: | + echo "🚀 Goldsky Subgraph Deployment" + echo " Subgraph: ${GOLDSKY_SUBGRAPH_NAME}" + echo " Version: ${SUBGRAPH_VERSION}" + echo " Tag: ${GOLDSKY_ROLLING_TAG}" + echo "" + + # --- Pre-flight: check current deployment --- + if [ -n "${GOLDSKY_ENDPOINT}" ]; then + echo "📊 Current deployment:" + CURRENT=$(curl -s -X POST -H "Content-Type: application/json" \ + -d '{"query":"{ _meta { deployment hasIndexingErrors block { number } } }"}' \ + "${GOLDSKY_ENDPOINT}" 2>/dev/null || true) + echo " $(echo "$CURRENT" | jq -c '.data._meta // "unavailable"' 2>/dev/null || echo 'unavailable')" + echo "" + fi + + # --- Deploy new version --- + echo "📤 Deploying ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}..." + set +e + DEPLOY_OUTPUT=$(goldsky subgraph deploy "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --path . \ + --token "${GOLDSKY_API_KEY}" 2>&1) + DEPLOY_EXIT=$? + set -e + + echo "$DEPLOY_OUTPUT" + + SKIP_DEPLOY=false + if [ $DEPLOY_EXIT -ne 0 ]; then + if echo "$DEPLOY_OUTPUT" | grep -qi "already exists"; then + echo "" + echo "ℹ️ Version ${SUBGRAPH_VERSION} already exists — skipping to tag" + SKIP_DEPLOY=true + elif echo "$DEPLOY_OUTPUT" | grep -qi "already deployed"; then + CONFLICT=$(echo "$DEPLOY_OUTPUT" | grep -oP 'under the name \K\S+(?=\.)' || true) + echo "" + echo "⚠️ Duplicate content detected — conflicts with ${CONFLICT}" + echo " Removing conflicting version and retrying..." + goldsky subgraph tag delete "${CONFLICT}" --tag "${GOLDSKY_ROLLING_TAG}" --token "${GOLDSKY_API_KEY}" --force 2>/dev/null || true + goldsky subgraph delete "${CONFLICT}" --token "${GOLDSKY_API_KEY}" --force 2>/dev/null || true + sleep 3 + echo "📤 Retrying deploy..." + goldsky subgraph deploy "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --path . \ + --token "${GOLDSKY_API_KEY}" + else + echo "" + echo "❌ Deployment failed" + exit 1 + fi + fi + + # Brief pause for Goldsky to register the new deployment + if [ "$SKIP_DEPLOY" = "false" ]; then + sleep 5 + fi + + # --- Roll the rolling tag --- + echo "" + echo "🏷️ Moving tag ${GOLDSKY_ROLLING_TAG} → ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" + + set +e + TAG_OUTPUT=$(goldsky subgraph tag create "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" 2>&1) + TAG_EXIT=$? + set -e + + if [ $TAG_EXIT -ne 0 ]; then + echo " Tag may exist on another version — moving it..." + LIST_OUTPUT=$(goldsky subgraph list --token "${GOLDSKY_API_KEY}" 2>/dev/null || true) + OLD_TAGGED=$(echo "$LIST_OUTPUT" \ + | grep "${GOLDSKY_SUBGRAPH_NAME}/" \ + | grep "${GOLDSKY_ROLLING_TAG}" \ + | awk '{print $1}' | head -1) + if [ -n "$OLD_TAGGED" ]; then + echo " Removing tag from ${OLD_TAGGED}..." + goldsky subgraph tag delete "${OLD_TAGGED}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" \ + --force 2>/dev/null || true + fi + goldsky subgraph tag create "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" + fi + + echo "" + echo "✅ ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION} deployed and tagged as ${GOLDSKY_ROLLING_TAG}" + + if [ "$SKIP_DEPLOY" = "true" ]; then + echo "deploy_status=already_exists" >> $GITHUB_OUTPUT + else + echo "deploy_status=deployed" >> $GITHUB_OUTPUT + fi + + echo "## 🚀 Goldsky Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Subgraph | \`${GOLDSKY_SUBGRAPH_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${SUBGRAPH_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${GOLDSKY_ROLLING_TAG}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Status | $([ "$SKIP_DEPLOY" = "true" ] && echo "Already existed" || echo "Deployed") |" >> $GITHUB_STEP_SUMMARY + + verify: + name: 🔍 Verify + runs-on: ubuntu-latest + needs: [build, deploy] + if: github.event_name != 'pull_request' + + steps: + - name: Verify deployment on Goldsky + env: + GOLDSKY_ENDPOINT: ${{ needs.build.outputs.goldsky_endpoint }} + run: | + echo "🔍 Verifying subgraph on Goldsky..." + echo " Subgraph: ${{ needs.build.outputs.goldsky_subgraph_name }}" + + if [ -z "${GOLDSKY_ENDPOINT}" ]; then + echo "⚠️ No Goldsky endpoint URL configured — skipping verification" + echo " Set DEV_GS_VAULT / STG_GS_VAULT / LMN_GS_VAULT org variable" + exit 0 + fi + + echo " Endpoint: ${GOLDSKY_ENDPOINT}" + + # Poll for up to 60 seconds, checking every 10s + for i in 1 2 3 4 5 6; do + sleep 10 + echo "" + echo " Attempt $i/6..." + + RESPONSE=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "{ _meta { block { number } deployment hasIndexingErrors } }"}' \ + "${GOLDSKY_ENDPOINT}") + + if echo "$RESPONSE" | jq -e '.errors' > /dev/null 2>&1; then + echo " Subgraph not ready yet..." + continue + fi + + HAS_ERRORS=$(echo "$RESPONSE" | jq -r '.data._meta.hasIndexingErrors // false') + BLOCK_NUMBER=$(echo "$RESPONSE" | jq -r '.data._meta.block.number // "unknown"') + + echo " Block: $BLOCK_NUMBER" + echo " Indexing errors: $HAS_ERRORS" + + if [ "$HAS_ERRORS" == "true" ]; then + echo "⚠️ Subgraph has indexing errors — check Goldsky dashboard" + exit 1 + fi + + echo "✅ Subgraph deployed and indexing on Goldsky" + exit 0 + done + + echo "⚠️ Subgraph did not become ready within 60s — may still be syncing" + echo " This is normal for new deployments. Check Goldsky dashboard." + exit 0 + + cleanup: + name: 🧹 Cleanup + runs-on: ubuntu-latest + needs: [setup, build, verify] + if: always() && github.event_name != 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + if: needs.verify.result == 'success' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: needs.verify.result == 'success' + run: | + TAG_NAME="${{ needs.setup.outputs.tag }}" + echo "🏷️ Creating tag: $TAG_NAME" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "⚠️ Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${{ needs.setup.outputs.version }} - Deployed to ${{ needs.setup.outputs.environment }}" + git push origin "$TAG_NAME" + echo "✅ Tag $TAG_NAME pushed" + fi + + - name: Summary + run: | + if [ "${{ needs.verify.result }}" == "success" ]; then + echo "## 🎉 Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Service:** Collateral Vault Subgraph" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ needs.setup.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.setup.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Target:** Goldsky" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Check logs above for details." >> $GITHUB_STEP_SUMMARY + fi + + notify: + name: 📢 Notify + runs-on: ubuntu-latest + needs: [setup, build, deploy, verify, cleanup] + if: always() && github.event_name != 'pull_request' + + steps: + - name: Checkout (for composite action) + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine status + id: status + run: | + if [ "${{ needs.verify.result }}" == "success" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "failure" ] || [ "${{ needs.deploy.result }}" == "failure" ] || [ "${{ needs.build.result }}" == "failure" ]; then + echo "status=failure" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "cancelled" ] || [ "${{ needs.deploy.result }}" == "cancelled" ]; then + echo "status=cancelled" >> $GITHUB_OUTPUT + else + echo "status=skipped" >> $GITHUB_OUTPUT + fi + + - name: Send Slack notification + uses: ./.github/actions/slack-notify + with: + status: ${{ steps.status.outputs.status }} + environment: ${{ needs.setup.outputs.environment }} + service_name: "Collateral Vault Subgraph" + version: ${{ needs.setup.outputs.version }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + additional_info: "${{ format('*Subgraph:* `{0}/{1}` → `{2}` • *Status:* {3}', needs.build.outputs.goldsky_subgraph_name, needs.build.outputs.version, needs.build.outputs.goldsky_rolling_tag, needs.deploy.outputs.deploy_status) }}" diff --git a/.github/workflows/indexer-tests.yml b/.github/workflows/indexer-tests.yml new file mode 100644 index 0000000..130dc8b --- /dev/null +++ b/.github/workflows/indexer-tests.yml @@ -0,0 +1,79 @@ +name: Indexer tests + +on: + pull_request: + paths: + - "indexer/**" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/indexer-tests.yml" + push: + branches: + - main + - stg + - dev + paths: + - "indexer/**" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/indexer-tests.yml" + +jobs: + pre: + name: Skip duplicate runs + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip-check.outputs.should_skip }} + steps: + - id: skip-check + uses: fkirc/skip-duplicate-actions@v5 + with: + concurrent_skipping: never + skip_after_successful_duplicate: "true" + paths_ignore: '["**/*.md"]' + + test: + name: Indexer tests + needs: pre + if: needs.pre.outputs.should_skip != 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ./indexer + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: indexer/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: indexer/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Render subgraph.yaml from the template using the dummy values shipped + # in .env.example so codegen has something to chew on. Real addresses + # are only needed at deploy time. + - name: Prepare subgraph (with .env.example defaults) + run: | + set -a && source .env.example && set +a + pnpm prepare:env + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Codegen + run: pnpm codegen + + - name: Build + run: pnpm build + + - name: Run matchstick tests + run: pnpm test diff --git a/indexer/.env.example b/indexer/.env.example new file mode 100644 index 0000000..9e794d3 --- /dev/null +++ b/indexer/.env.example @@ -0,0 +1,13 @@ +# ── Subgraph manifest ────────────────────────────────────────────────────── +NETWORK=arbitrum-sepolia +VAULT_ADDRESS=0x0000000000000000000000000000000000000000 +VAULT_START_BLOCK=0 + +# Authorized engine addresses, exposed to mappings via the data source `context` +# block in subgraph.template.yaml. Used to bucket internal Transfer events into +# PERPS / OPTIONS / OTHER. +PERPS_ADDRESS=0x0000000000000000000000000000000000000000 +OPTIONS_ADDRESS=0x0000000000000000000000000000000000000000 + +# ── docker-compose (graph-node) ──────────────────────────────────────────── +ETH_NODE_ADDRESS=https://arb-sepolia.g.alchemy.com/v2/YOUR_KEY diff --git a/indexer/.gitignore b/indexer/.gitignore new file mode 100644 index 0000000..a7c8ec5 --- /dev/null +++ b/indexer/.gitignore @@ -0,0 +1,11 @@ +build +data +generated +subgraph.yaml +.env* +!.env.example +node_modules + +# Matchstick test runtime artifacts +tests/.bin/ +tests/.latest.json diff --git a/indexer/README.md b/indexer/README.md new file mode 100644 index 0000000..621da49 --- /dev/null +++ b/indexer/README.md @@ -0,0 +1,217 @@ +# Vault Indexer + +A Graph Protocol subgraph that indexes the `CollateralVault` contract — turning on-chain events into a queryable GraphQL API for collateral balances, deposit / withdrawal history, and PnL flows attributable to each product engine (perps, options, …). + +It is the **single source of truth for vault state**. Product subgraphs (`perps`, `options`, …) intentionally do **not** track collateral and instead rely on this subgraph for any user-balance / deposit-history queries. + +## Schema + +### Entities + +| Entity | Mutability | Description | +| --- | --- | --- | +| **Vault** | mutable | Singleton (id=0). Contract identity, lifetime aggregates, and current `totalSupply` / `insuranceFundBalance`. | +| **VaultUser** | mutable | Per-address account: current `balance`, lifetime deposit / withdrawal totals, and signed net of internal transfers (overall + per caller-category). | +| **VaultDeposit** | immutable | One per `Deposited` event. Tracks recipient, amount, and the funding `sender`. `isInsuranceFund` distinguishes `depositInsuranceFund` flow. | +| **VaultWithdrawal** | immutable | One per `Withdrawn` event. Tracks owner, recipient, amount. `isInsuranceFund` distinguishes `withdrawInsuranceFund` flow. | +| **VaultInternalTransfer** | immutable | One per `Transfer` event with `from != 0x0 && to != 0x0` (i.e. `internalTransfer` / `internalTransferWithMarginCheck`). Tagged with a `callerCategory` derived from `transaction.to`. | + +### Caller attribution + +`internalTransfer` does not emit which engine called it. As a heuristic, the indexer compares `transaction.to` against the configured `PERPS_ADDRESS` / `OPTIONS_ADDRESS` (from `.env`): + +| transaction.to | callerCategory | +| --- | --- | +| `PERPS_ADDRESS` | `PERPS` | +| `OPTIONS_ADDRESS` | `OPTIONS` | +| anything else | `OTHER` | + +This is accurate for "EOA → engine → vault" flows, which is the dominant pattern. A multi-hop tx (router → engine → vault) would land in `OTHER`. If you need exact attribution add a richer event to the contract. + +### Event handlers + +| Event | What it does | +| --- | --- | +| `Initialized(uint64)` | Bootstraps the `Vault` singleton (collateral-token, margin-engine, decimals). | +| `Transfer(address,address,uint256)` | **Single source of truth for balances**: mint = deposit, burn = withdrawal, internal = `VaultInternalTransfer`. Updates `VaultUser.balance`, `Vault.totalSupply`, `Vault.insuranceFundBalance`, and signed `netInternalIn` / `netFrom*` totals. | +| `Deposited(address,uint256,address)` | Creates the `VaultDeposit` entity and bumps `VaultUser` / `Vault` deposit aggregates. | +| `Withdrawn(address,uint256,address)` | Creates the `VaultWithdrawal` entity and bumps `VaultUser` / `Vault` withdrawal aggregates. | +| `InsuranceFundDeposited(address,uint256)` | Bumps `Vault.insuranceFundDeposited`. (The actual `VaultDeposit` entity is created by the paired `Deposited` event with `isInsuranceFund = true`.) | +| `InsuranceFundWithdrawn(address,uint256)` | Bumps `Vault.insuranceFundWithdrawn`. | + +### Why both `Transfer` and `Deposited` / `Withdrawn`? + +`CollateralVault` extends `ERC20Upgradeable` but disables the public ERC20 surface. All balance changes still flow through `_mint` / `_burn` / `_transfer`, which emit `Transfer`. Using `Transfer` as the balance ledger is lossless and trivially correct. The `Deposited` / `Withdrawn` events carry semantic context that `Transfer` does not (the funding `sender` and the withdrawal `recipient`), so we keep them as **entity-creation handlers** while delegating balance math entirely to `Transfer`. + +## Local Development + +### Prerequisites + +- Docker (for graph-node, IPFS, and Postgres) +- pnpm +- An Ethereum node URL for graph-node to connect to + +### 1. Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env`: + +``` +NETWORK=arbitrum-sepolia +VAULT_ADDRESS=0x... +VAULT_START_BLOCK=123456 +PERPS_ADDRESS=0x... +OPTIONS_ADDRESS=0x... +ETH_NODE_ADDRESS=https://arb-sepolia.g.alchemy.com/v2/YOUR_KEY +``` + +`PERPS_ADDRESS` / `OPTIONS_ADDRESS` are injected into the data source `context` block in `subgraph.yaml` via `envsubst` and read at runtime via `dataSource.context()`, so re-run `pnpm prepare-local` if you change them. + +### 2. Start infrastructure + +```bash +pnpm indexer # docker-compose up (graph-node + IPFS + Postgres) +``` + +### 3. Build and deploy + +```bash +pnpm setup-local +``` + +Or step by step: + +```bash +pnpm prepare-local # Substitute env vars into subgraph.yaml +pnpm codegen # Generate AssemblyScript types from schema + ABI +pnpm build # Compile the subgraph +pnpm create-local # Register subgraph name with graph-node +pnpm deploy-local # Deploy to local graph-node +``` + +### 4. Query + +``` +http://localhost:8000/subgraphs/name/collateral-vault +``` + +## Available Scripts + +| Script | Description | +| --- | --- | +| `pnpm indexer` | Start graph-node + IPFS + Postgres via Docker Compose | +| `pnpm setup-local` | Full local pipeline: prepare, codegen, build, create, deploy | +| `pnpm prepare-local` | Substitute `.env` vars into `subgraph.yaml` | +| `pnpm codegen` | Generate AssemblyScript types | +| `pnpm build` | Compile the subgraph | +| `pnpm create-local` | Register subgraph with local graph-node | +| `pnpm deploy-local` | Deploy subgraph to local graph-node | +| `pnpm remove-local` | Remove subgraph from local graph-node | +| `pnpm deploy` | Deploy to The Graph Studio (hosted) | +| `pnpm test` | Run Matchstick unit tests | +| `pnpm clean` | Remove generated files, build artifacts, and data | + +## Configuration + +The subgraph manifest is generated from `subgraph.template.yaml` using `envsubst` from the parent `.env`. Recognised template vars: + +| Var | Used by | Notes | +| --- | --- | --- | +| `NETWORK` | manifest, docker-compose | e.g. `arbitrum-sepolia` | +| `VAULT_ADDRESS` | manifest | Deployed `CollateralVault` proxy address | +| `VAULT_START_BLOCK` | manifest | First block to index | +| `PERPS_ADDRESS` | manifest `context` | Used to bucket internal transfers | +| `OPTIONS_ADDRESS` | manifest `context` | Used to bucket internal transfers | +| `ETH_NODE_ADDRESS` | docker-compose | RPC endpoint for graph-node | + +The ABI is read from `../contracts/abi/CollateralVault.json`, so the contracts package must be built (`pnpm -C contracts compile`) before running `pnpm codegen`. + +## Example Queries + +**Vault stats:** + +```graphql +{ + vault(id: 0) { + totalDeposited + totalWithdrawn + totalSupply + insuranceFundBalance + insuranceFundDeposited + insuranceFundWithdrawn + totalUsers + depositCount + withdrawalCount + internalTransferCount + } +} +``` + +**User portfolio:** + +```graphql +{ + vaultUser(id: "0x...") { + balance + totalDeposited + totalWithdrawn + netFromPerps + netFromOptions + netInternalIn + } +} +``` + +**Deposit history:** + +```graphql +{ + vaultDeposits( + where: { user: "0x..." } + orderBy: timestamp + orderDirection: desc + first: 50 + ) { + amount + sender + isInsuranceFund + timestamp + transactionHash + } +} +``` + +**Internal transfers attributed to perps (e.g. PnL settlement timeline):** + +```graphql +{ + vaultInternalTransfers( + where: { callerCategory: PERPS } + orderBy: timestamp + orderDirection: desc + first: 50 + ) { + from { address } + to { address } + amount + timestamp + transactionHash + } +} +``` + +**Top recipients of perps-bucketed flow:** + +```graphql +{ + vaultUsers(first: 10, orderBy: netFromPerps, orderDirection: desc) { + address + netFromPerps + netInternalIn + balance + } +} +``` diff --git a/indexer/assembly.d.ts b/indexer/assembly.d.ts new file mode 100644 index 0000000..4e7ebb3 --- /dev/null +++ b/indexer/assembly.d.ts @@ -0,0 +1,9 @@ +// Ambient declarations for AssemblyScript built-in types. +// Silences TypeScript language server errors in .ts files compiled by asc. +declare type i32 = number; +declare type u8 = number; +declare type i64 = number; +declare type u64 = number; +declare type f32 = number; +declare type f64 = number; +declare type bool = boolean; diff --git a/indexer/docker-compose.yml b/indexer/docker-compose.yml new file mode 100644 index 0000000..2d9b3cd --- /dev/null +++ b/indexer/docker-compose.yml @@ -0,0 +1,50 @@ +services: + graph-node: + image: graphprotocol/graph-node:v0.41.1 + ports: + - "8000:8000" + - "8001:8001" + - "8020:8020" + - "8030:8030" + - "8040:8040" + depends_on: + - ipfs + - postgres + extra_hosts: + - localhost:host-gateway + environment: + postgres_host: postgres + postgres_user: graph-node + postgres_pass: let-me-in + postgres_db: graph-node + ipfs: "ipfs:5001" + ethereum: "${NETWORK}:${ETH_NODE_ADDRESS}" + GRAPH_ETHEREUM_MAX_BLOCK_RANGE_SIZE: 10000 + GRAPH_LOG: debug + ipfs: + image: ipfs/kubo:v0.17.0 + ports: + - "5001:5001" + environment: + IPFS_SWARM_KEY_FILE: /dev/null + command: ["daemon", "--offline"] + volumes: + - ./data/ipfs:/data/ipfs + postgres: + image: postgres:14 + ports: + - "5432:5432" + command: + [ + "postgres", + "-cshared_preload_libraries=pg_stat_statements", + "-cmax_connections=200", + ] + environment: + POSTGRES_USER: graph-node + POSTGRES_PASSWORD: let-me-in + POSTGRES_DB: graph-node + PGDATA: "/var/lib/postgresql/data" + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" + volumes: + - ./data/postgres:/var/lib/postgresql/data diff --git a/indexer/package.json b/indexer/package.json new file mode 100644 index 0000000..11b6e94 --- /dev/null +++ b/indexer/package.json @@ -0,0 +1,34 @@ +{ + "name": "vault-indexer", + "license": "UNLICENSED", + "engines": { + "node": ">=22.6.0" + }, + "type": "module", + "scripts": { + "clean": "rm -rf data generated build subgraph.yaml", + "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", + "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", + "codegen": "graph codegen", + "build": "graph build", + "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ collateral-vault", + "create-local": "graph create --node http://localhost:8020/ collateral-vault", + "remove-local": "graph remove --node http://localhost:8020/ collateral-vault", + "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 collateral-vault", + "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", + "test": "graph test", + "indexer": "docker compose --env-file ../.env up", + "graph:api": "open http://localhost:8030/graphql/playground" + }, + "dependencies": { + "@graphprotocol/graph-ts": "0.38.2" + }, + "devDependencies": { + "@graphprotocol/graph-cli": "^0.98.1", + "@types/node": "^25.3.0", + "assemblyscript": "^0.19.23", + "matchstick-as": "0.6.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" +} \ No newline at end of file diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml new file mode 100644 index 0000000..63cc38c --- /dev/null +++ b/indexer/pnpm-lock.yaml @@ -0,0 +1,3578 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@graphprotocol/graph-ts': + specifier: 0.38.2 + version: 0.38.2 + devDependencies: + '@graphprotocol/graph-cli': + specifier: ^0.98.1 + version: 0.98.1(@types/node@25.6.0)(typescript@5.9.3)(zod@3.25.76) + '@types/node': + specifier: ^25.3.0 + version: 25.6.0 + assemblyscript: + specifier: ^0.19.23 + version: 0.19.23 + matchstick-as: + specifier: 0.6.0 + version: 0.6.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@chainsafe/is-ip@2.1.0': + resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} + + '@chainsafe/netmask@2.0.0': + resolution: {integrity: sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg==} + + '@dnsquery/dns-packet@6.1.1': + resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} + engines: {node: '>=6'} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5': + resolution: {integrity: sha512-yZ0H5e3EpAYKokX/AbtplzlvSxEJY7ZfpvQyDzyODkks0hakAAlDG6fQu1SlDJMWorY7bbq1j7fCiFeTWci6TA==} + hasBin: true + + '@graphprotocol/graph-cli@0.98.1': + resolution: {integrity: sha512-GrWFcRCBlLcRT+gIGundQl7yyrX3YWUPj66bxThKf5CJvvWXdZoNxrj27dMMqulsSwYmpCkb3YmpCiVJFGdpHw==} + engines: {node: '>=20.18.1'} + hasBin: true + + '@graphprotocol/graph-ts@0.38.2': + resolution: {integrity: sha512-87KIFSFs2+Te+mnmb7Y+M57oqzlLy20cIyPIRbn9qJfpZFSZHTKtBLT6KQmcsK0YkoWis9Ur3c3M2c9mmaaEHQ==} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ipld/dag-cbor@9.2.6': + resolution: {integrity: sha512-vZGJ84Em2jCVAS7td5gc08YTVN8/s4bTQxg4pU77PAXDAR/yLYOthOvkCu01fdl1lrZwz47RdUterxdkrs3p5A==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + '@ipld/dag-json@10.2.7': + resolution: {integrity: sha512-G+pXbOV6JpNUQrB+4H+0apnE85M9V0JjSjc7Mm2DdKbC6Qn8so9UYIGnJps+ZRMAvGUieO2iCZbAFLeWE2snvA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + '@ipld/dag-pb@4.1.5': + resolution: {integrity: sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@libp2p/crypto@5.1.17': + resolution: {integrity: sha512-gzn9b3tX9D5xCiXb36PF0rH16kGkLW5ESbT+nmXKUp1HCDD30RXQT/oHSylz5I3GN39BC1C3hBOBNaIQYuO+qw==} + + '@libp2p/interface@2.11.0': + resolution: {integrity: sha512-0MUFKoXWHTQW3oWIgSHApmYMUKWO/Y02+7Hpyp+n3z+geD4Xo2Rku2gYWmxcq+Pyjkz6Q9YjDWz3Yb2SoV2E8Q==} + + '@libp2p/interface@3.2.2': + resolution: {integrity: sha512-IU78g6uF8Ls0//4v9VE1rL5Jvy+i6I8LI/DssojFICbaDJSkL59Sn5XRfHrY5OCxTnUnUxnWK7pHz/3+UZcRNQ==} + + '@libp2p/logger@5.2.0': + resolution: {integrity: sha512-OEFS529CnIKfbWEHmuCNESw9q0D0hL8cQ8klQfjIVPur15RcgAEgc1buQ7Y6l0B6tCYg120bp55+e9tGvn8c0g==} + + '@libp2p/peer-id@5.1.9': + resolution: {integrity: sha512-cVDp7lX187Epmi/zr0Qq2RsEMmueswP9eIxYSFoMcHL/qcvRFhsxOfUGB8361E26s2WJvC9sXZ0oJS9XVueJhQ==} + + '@multiformats/dns@1.0.13': + resolution: {integrity: sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==} + + '@multiformats/multiaddr-to-uri@11.0.2': + resolution: {integrity: sha512-SiLFD54zeOJ0qMgo9xv1Tl9O5YktDKAVDP4q4hL16mSq4O4sfFNagNADz8eAofxd6TfQUzGQ3TkRRG9IY2uHRg==} + + '@multiformats/multiaddr@12.5.1': + resolution: {integrity: sha512-+DDlr9LIRUS8KncI1TX/FfUn8F2dl6BIxJgshS/yFQCNB5IAF0OGzcwB39g5NLE22s4qqDePv0Qof6HdpJ/4aQ==} + + '@multiformats/multiaddr@13.0.1': + resolution: {integrity: sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g==} + + '@noble/curves@1.4.2': + resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oclif/core@4.11.0': + resolution: {integrity: sha512-nTkRMgxFlIKQIIYGvhO2JMsLSQ1aHPHblHfFgxgoBrGK8Ao/8wxc4eNOIv/+t8dMXliZd7mREVr6la4aXXXg5A==} + engines: {node: '>=18.0.0'} + + '@oclif/core@4.5.5': + resolution: {integrity: sha512-iQzlaJQgPeUXrtrX71OzDwxPikQ7c2FhNd8U8rBB7BCtj2XYfmzBT/Hmbc+g9OKDIG/JkbJT0fXaWMMBrhi+1A==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-autocomplete@3.2.46': + resolution: {integrity: sha512-TFvuD6JlmqEVsEvMqunyj3cyCz/l2Q4MqCjp/XtlSLS9x3xTlam7PGlqWi4WAhxl/K8CtpYqVlMYFEnlLTHspw==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-not-found@3.2.81': + resolution: {integrity: sha512-M88tLONBH36hLAbkFbmCo1hoZPSdU5l8Px1xEIlIgSmGMam+CoAzx4kGqpLbokgfpaHeP8/Jx3QJ18u9ef/2Qw==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-warn-if-update-available@3.1.61': + resolution: {integrity: sha512-4XcrTxcCs+brR/eZ0BPeuiREiH3USlJiaHbUqPhnIBuyxhhUSYVd8ZO6s5MQN7AXJq4SMQ+B5zLaHq+ep/afIw==} + engines: {node: '>=18.0.0'} + + '@pinax/graph-networks-registry@0.7.1': + resolution: {integrity: sha512-Gn2kXRiEd5COAaMY/aDCRO0V+zfb1uQKCu5HFPoWka+EsZW27AlTINA7JctYYYEMuCbjMia5FBOzskjgEvj6LA==} + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.2': + resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} + engines: {node: '>=12'} + + '@rescript/std@9.0.0': + resolution: {integrity: sha512-zGzFsgtZ44mgL4Xef2gOy1hrRVdrs9mcxCOOKZrIPsmbZW14yTkaF591GXxpQvjXiHtgZ/iA9qLyWH6oSReIxQ==} + + '@scure/base@1.1.9': + resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + + '@scure/bip32@1.4.0': + resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + + '@scure/bip39@1.3.0': + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.5': + resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + abitype@0.7.1: + resolution: {integrity: sha512-VBkRHTDZf9Myaek/dO3yMmOzB/y2s3Zo6nVU7yaw1G+TvCHAjwaJzNGN9yo4K5D8bU/VZXKP1EJpRhFr862PlQ==} + peerDependencies: + typescript: '>=4.9.4' + zod: ^3 >=3.19.1 + peerDependenciesMeta: + zod: + optional: true + + abort-error@1.0.2: + resolution: {integrity: sha512-lVgvB2NyPLqbXXhVmXcYFTC1x5K7CiVdPgdY7LGgFQWC8506oN01sPN3i9cl9ynuwF4iJ0TS9exnR7cZ9FuX4w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + + any-signal@4.2.0: + resolution: {integrity: sha512-LndMvYuAPf4rC195lk7oSFuHOYFpOszIYrNYv0gHAvz+aEhE9qPZLhmrIz5pXP2BSsPOXvsuHDXEGaiQhIh9wA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + apisauce@2.1.6: + resolution: {integrity: sha512-MdxR391op/FucS2YQRfB/NMRyCnHEPDd4h17LRIuVYi0BpGmMhpxc0shbOpfs5ahABuBEffNCGal5EcsydbBWg==} + + app-module-path@2.2.0: + resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assemblyscript@0.19.23: + resolution: {integrity: sha512-fwOQNZVTMga5KRsfY80g7cpOl4PsFQczMwHzdtgoqLXaYhkhavufKb0sB0l3T1DUxpAufA0KNhlbpuuhZUwxMA==} + hasBin: true + + assemblyscript@0.27.31: + resolution: {integrity: sha512-Ra8kiGhgJQGZcBxjtMcyVRxOEJZX64kd+XGpjWzjcjgxWJVv+CAQO0aDBk4GQVhjYbOkATarC83mHjAVGtwPBQ==} + engines: {node: '>=16', npm: '>=7'} + hasBin: true + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@0.21.4: + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binaryen@102.0.0-nightly.20211028: + resolution: {integrity: sha512-GCJBVB5exbxzzvyt8MGDv/MeUjs6gkXDvf4xOIItRBptYl0Tz5sm1o/uG95YK0L0VeG5ajDu3hRtkBP2kzqC5w==} + hasBin: true + + binaryen@116.0.0-nightly.20240114: + resolution: {integrity: sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==} + hasBin: true + + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + + blob-to-it@2.0.12: + resolution: {integrity: sha512-0zEZt8t8/QrdH4boktG19F/9fqfPWFjuh1QlK0qTCO13oUWaBAR8kpNloQNb3OWUtaA0mu8qfPy0R3CZDC8M2g==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-readablestream-to-it@2.0.12: + resolution: {integrity: sha512-VDAcuM39JVtxZ7auqE2p0zHYk1fq+pac0cWLOQJ48MIChTZ1RjCR2PYCdL3kIisst7oGZCxYrJhfHlbNYIa0Tg==} + + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + cborg@5.1.1: + resolution: {integrity: sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==} + hasBin: true + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.0: + resolution: {integrity: sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@7.0.1: + resolution: {integrity: sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==} + engines: {node: '>=10'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dag-jose@5.1.1: + resolution: {integrity: sha512-9alfZ8Wh1XOOMel8bMpDqWsDT72ojFQCJPtwZSev9qh4f8GoCV9qrJW8jcOUhcstO8Kfm09FHGo//jqiZq3z9w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-tar@4.1.1: + resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} + engines: {node: '>=4'} + + decompress-tarbz2@4.1.1: + resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} + engines: {node: '>=4'} + + decompress-targz@4.1.1: + resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} + engines: {node: '>=4'} + + decompress-unzip@4.0.1: + resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} + engines: {node: '>=4'} + + decompress@4.2.1: + resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} + engines: {node: '>=4'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + + docker-compose@1.3.0: + resolution: {integrity: sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==} + engines: {node: '>= 6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + ejs@3.1.8: + resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-fetch@1.9.1: + resolution: {integrity: sha512-M9qw6oUILGVrcENMSRRefE1MbHPIz0h79EKIeJWK9v563aT9Qkh8aEHPO1H5vi970wPirNY+jO9OpFoLiMsMGA==} + engines: {node: '>=6'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + ethereum-cryptography@2.2.1: + resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-levenshtein@3.0.0: + resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + file-type@5.2.0: + resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} + engines: {node: '>=4'} + + file-type@6.2.0: + resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} + engines: {node: '>=4'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + + fs-jetpack@4.3.1: + resolution: {integrity: sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-iterator@1.0.2: + resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gluegun@5.2.0: + resolution: {integrity: sha512-jSUM5xUy2ztYFQANne17OUm/oAd7qSX7EBksS9bQDt9UvLPqcEkeWUebmaposb8Tx7eTTD8uJVWGRe6PYSsYkg==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql-import-node@0.0.5: + resolution: {integrity: sha512-OXbou9fqh9/Lm7vwXT0XoRN9J5+WCYKnbiTalgFDvkQERITRmcfncZs6aVABedd5B85yQU5EULS4a5pnbpuI0Q==} + peerDependencies: + graphql: '*' + + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashlru@2.3.0: + resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + http-call@5.3.0: + resolution: {integrity: sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==} + engines: {node: '>=8.0.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + interface-datastore@8.3.2: + resolution: {integrity: sha512-R3NLts7pRbJKc3qFdQf+u40hK8XWc0w4Qkx3OFEstC80VoaDUABY/dXA2EJPhtNC+bsrf41Ehvqb6+pnIclyRA==} + + interface-store@6.0.3: + resolution: {integrity: sha512-+WvfEZnFUhRwFxgz+QCQi7UC6o9AM0EHM9bpIe2Nhqb100NHCsTvNAn4eJgvgV2/tmLo1MP9nGxQKEcZTAueLA==} + + ipfs-unixfs@11.2.5: + resolution: {integrity: sha512-uasYJ0GLPbViaTFsOLnL9YPjX5VmhnqtWRriogAHOe4ApmIi9VAOFBzgDHsUW2ub4pEa/EysbtWk126g2vkU/g==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-natural-number@4.0.1: + resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-retry-allowed@1.2.0: + resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} + engines: {node: '>=0.10.0'} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iso-url@1.2.1: + resolution: {integrity: sha512-9JPDgCN4B7QPkLtYAAOrEuAWvP9rWvR5offAr0/SeF046wIkglqH3VXgYYP6NcsKslH80UIVgmPqNe3j7tG2ng==} + engines: {node: '>=12'} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + it-all@3.0.11: + resolution: {integrity: sha512-Gvqj6MO4GMLnFdtE68HZRpGBskNC+9+GQ+JevTGNYLyhjUuPhjDLU3jN1LpBemXJDW1bRSkczqA/qGyKlPKrcQ==} + + it-first@3.0.11: + resolution: {integrity: sha512-0ig8DKpg09V1o7JBagm3oPx3VY7WYfU5w3lpbLbqzijnfMPSvMGoMZuLm17h/RgOJXKP+9mt7vsCNiU2TW8TkQ==} + + it-glob@3.0.6: + resolution: {integrity: sha512-dFNeW4izM08QuB4uuIr+sVKUSo8ftVD/E1RnYidiUZx/i9h9mmwDSBl3kPv/TCah6HI0y1sgfHVCbrwA9FjoaQ==} + + it-last@3.0.11: + resolution: {integrity: sha512-Fg571l81nPzhZsiYjkw4dkhRqAK4oqIamTPEfAOnXI/5pYXz+dIfMVYmh9ncZs58oFNMkdF3bYFuCBTw/xJK0w==} + + it-map@3.1.6: + resolution: {integrity: sha512-wCix0FXImtIPIxhCnbz35RqWs00e/CReSZX9nZq1j46JcAzBBp57ob9/2l1WnDYEaUURIR8xCyg2NsWbOwBJFQ==} + + it-peekable@3.0.10: + resolution: {integrity: sha512-2E6+p1pelZOhzp69aaiiBuEybWzAl10uYbIdCR3Pxy8bFNnS/kgpbLtGbNbIZ6RVdU7yHHkmATYwjy52GfFEKA==} + + it-pushable@3.2.3: + resolution: {integrity: sha512-gzYnXYK8Y5t5b/BnJUr7glfQLO4U5vyb05gPx/TyTw+4Bv1zM9gFk4YsOrnulWefMewlphCjKkakFvj1y99Tcg==} + + it-stream-types@2.0.4: + resolution: {integrity: sha512-tsX+klvMQ53J4Jm2B52vCIs7WD609ck+VS9X2TKMEv7VPY9VwaYKmSWyHek5QS0wHBtP0bWj9KMqCtAHgVKiXw==} + + it-to-stream@1.0.0: + resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jayson@4.2.0: + resolution: {integrity: sha512-VfJ9t1YLwacIubLhONk0KFeosUBwstRWQ0IRT1KDjEjnVnSOVHC3uwugyV7L0c7R9lpVyrUGT2XWiBA1UTtpyg==} + engines: {node: '>=8'} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kubo-rpc-client@5.4.1: + resolution: {integrity: sha512-v86bQWtyA//pXTrt9y4iEwjW6pt1gA18Z1famWXIR/HN5TFdYwQ3yHOlRE6JSWBDQ0rR6FOMyrrGy8To78mXow==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.lowercase@4.3.0: + resolution: {integrity: sha512-UcvP1IZYyDKyEL64mmrwoA1AbFu5ahojhTtkOUr1K9dbuxzS9ev8i4TxMMGCqRC9TE8uDaSoufNAXxRPNTseVA==} + + lodash.lowerfirst@4.3.1: + resolution: {integrity: sha512-UUKX7VhP1/JL54NXg2aq/E1Sfnjjes8fNYTNkPU8ZmsaVeBvPHKdbNaN79Re5XRL01u6wbq3j0cbYZj71Fcu5w==} + + lodash.pad@4.5.1: + resolution: {integrity: sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==} + + lodash.padend@4.6.1: + resolution: {integrity: sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==} + + lodash.padstart@4.6.1: + resolution: {integrity: sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==} + + lodash.repeat@4.1.0: + resolution: {integrity: sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.trim@4.18.0: + resolution: {integrity: sha512-q8B9MlXzN9NaTtS2JCd7kKl3RqwrVURgKEXoHDII8A/v7y3tWOq3rLEe+vN6LNvT+EYBVKVt6roNQxMkosS2aA==} + + lodash.trimend@4.18.0: + resolution: {integrity: sha512-8w2M3nZAWLN1OX/6mTPCwRlZiD/LhVyPV9l7DEbkd9wybExvg9AcCjbD19swj6oVzX5hcMZHp3/Y1b4Sl3sHKg==} + + lodash.trimstart@4.5.1: + resolution: {integrity: sha512-b/+D6La8tU76L/61/aN0jULWHkT0EeJCmVstPBn/K9MtD2qBW83AsBNrr63dKuWYwVMO7ucv13QNO/Ek/2RKaQ==} + + lodash.uppercase@4.3.0: + resolution: {integrity: sha512-+Nbnxkj7s8K5U8z6KnEYPGUOGp3woZbB7Ecs7v3LkkjLQSm2kP9SKIILitN1ktn2mB/tmM9oSlku06I+/lH7QA==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@3.0.0: + resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==} + engines: {node: '>=8'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + main-event@1.0.4: + resolution: {integrity: sha512-sKazUjIy2Jalv5lkQ446iOcrx8Q7TkaCuk6xfnzg5uUqMusMLDMPmRDmSNE2kjSVpSTJo4j1bQZusS+Ib7Bvrg==} + + make-dir@1.3.0: + resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} + engines: {node: '>=4'} + + matchstick-as@0.6.0: + resolution: {integrity: sha512-E36fWsC1AbCkBFt05VsDDRoFvGSdcZg6oZJrtIe/YDBbuFh8SKbR5FcoqDhNWqSN+F7bN/iS2u8Md0SM+4pUpw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + ms@3.0.0-canary.202508261828: + resolution: {integrity: sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==} + engines: {node: '>=18'} + + multiformats@13.1.3: + resolution: {integrity: sha512-CZPi9lFZCM/+7oRolWYsvalsyWQGFo+GpdaTmjxXXomC+nP/W1Rnxb9sUgjvmNmRZ5bOPqRAl4nuK+Ydw/4tGw==} + + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + + native-fetch@4.0.2: + resolution: {integrity: sha512-4QcVlKFtv2EYVS5MBgsGX5+NWKtbDbIECdUXDBGDMAZXq3Jkv9zf+y8iS7Ub8fEdga3GpYeazp9gauNqXHJOCg==} + peerDependencies: + undici: '*' + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + ora@4.0.2: + resolution: {integrity: sha512-YUOZbamht5mfLxPmk4M35CD/5DuOkAacxlEUbStVXpBAt4fyhBf+vZHI/HRkI++QUp3sNoeA2Gw4C+hi4eGSig==} + engines: {node: '>=8'} + + p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + + p-fifo@1.0.0: + resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} + + p-queue@9.2.0: + resolution: {integrity: sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-duration@2.1.6: + resolution: {integrity: sha512-1/A2Exg3NcJGcYdgV/dn4frR7vO2hOW/ohQ4KIgbT4W3raVcpYSszPWiL6I6cKufi4jQM5NbGRXLBj8AoLM4iQ==} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress-events@1.1.0: + resolution: {integrity: sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + protons-runtime@5.6.0: + resolution: {integrity: sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==} + + protons-runtime@6.0.1: + resolution: {integrity: sha512-ONL+jDj143WA1m+WKLuuqBIaDKxm32dx6HfJdyujrRcni/6KkhXzVnyg22nH/Wwqmbwnd1BKUVkD1hMEWZFeww==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-native-fetch-api@3.0.0: + resolution: {integrity: sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + seek-bzip@1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + + semver@7.3.5: + resolution: {integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + stream-to-it@1.0.1: + resolution: {integrity: sha512-AqHYAYPHcmvMrcLNgncE/q0Aj/ajP6A4qGhxP6EVn7K3YTNs0bJpJyk57wc2Heb7MUL64jurvmnmui8D9kjZgA==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-dirs@2.1.0: + resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-stream@1.6.2: + resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} + engines: {node: '>= 0.8.0'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uint8-varint@2.0.4: + resolution: {integrity: sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw==} + + uint8arraylist@2.4.9: + resolution: {integrity: sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==} + + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + utf8-codec@1.0.0: + resolution: {integrity: sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + wabt@1.0.24: + resolution: {integrity: sha512-8l7sIOd3i5GWfTWciPL0+ff/FK/deVK2Q6FN+MPz4vfUcD78i2M/49XJTwF6aml91uIiuXJEsLKWMB2cw/mtKg==} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + weald@1.1.1: + resolution: {integrity: sha512-PaEQShzMCz8J/AD2N3dJMc1hTZWkJeLKS2NMeiVkV5KDHwgZe7qXLEzyodsT/SODxWDdXJJqocuwf3kHzcXhSQ==} + + web3-errors@1.3.1: + resolution: {integrity: sha512-w3NMJujH+ZSW4ltIZZKtdbkbyQEvBzyp3JRn59Ckli0Nz4VMsVq8aF1bLWM7A2kuQ+yVEm3ySeNU+7mSRwx7RQ==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-eth-abi@4.4.1: + resolution: {integrity: sha512-60ecEkF6kQ9zAfbTY04Nc9q4eEYM0++BySpGi8wZ2PD1tw/c0SDvsKhV6IKURxLJhsDlb08dATc3iD6IbtWJmg==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-types@1.10.0: + resolution: {integrity: sha512-0IXoaAFtFc8Yin7cCdQfB9ZmjafrbP6BO0f0KT/khMhXKUpoJ6yShrVhiNpyRBo8QQjuOagsWzwSK2H49I7sbw==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-utils@4.3.3: + resolution: {integrity: sha512-kZUeCwaQm+RNc2Bf1V3BYbF29lQQKz28L0y+FA4G0lS8IxtJVGi5SeDTUkpwqqkdHHC7JcapPDnyyzJ1lfWlOw==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-validator@2.0.6: + resolution: {integrity: sha512-qn9id0/l1bWmvH4XfnG/JtGKKwut2Vokl6YXP5Kfg424npysmtRLe9DgiNBM9Op7QL/aSiaA0TVXibuIuWcizg==} + engines: {node: '>=14', npm: '>=6.12.0'} + + wherearewe@2.0.1: + resolution: {integrity: sha512-XUguZbDxCA2wBn2LoFtcEhXL6AXo+hVjGonwhSTTTU9SzbWG8Xu3onNIpzf9j/mYUcJQ0f+m37SzG77G851uFw==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@chainsafe/is-ip@2.1.0': {} + + '@chainsafe/netmask@2.0.0': + dependencies: + '@chainsafe/is-ip': 2.1.0 + + '@dnsquery/dns-packet@6.1.1': + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + utf8-codec: 1.0.0 + + '@fastify/busboy@3.2.0': {} + + '@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5': + dependencies: + '@rescript/std': 9.0.0 + graphql: 16.11.0 + graphql-import-node: 0.0.5(graphql@16.11.0) + js-yaml: 4.1.0 + + '@graphprotocol/graph-cli@0.98.1(@types/node@25.6.0)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@float-capital/float-subgraph-uncrashable': 0.0.0-internal-testing.5 + '@oclif/core': 4.5.5 + '@oclif/plugin-autocomplete': 3.2.46 + '@oclif/plugin-not-found': 3.2.81(@types/node@25.6.0) + '@oclif/plugin-warn-if-update-available': 3.1.61 + '@pinax/graph-networks-registry': 0.7.1 + '@whatwg-node/fetch': 0.10.13 + assemblyscript: 0.19.23 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + decompress: 4.2.1 + docker-compose: 1.3.0 + fs-extra: 11.3.2 + glob: 11.0.3 + gluegun: 5.2.0(debug@4.4.3) + graphql: 16.11.0 + immutable: 5.1.4 + jayson: 4.2.0 + js-yaml: 4.1.0 + kubo-rpc-client: 5.4.1(undici@7.16.0) + open: 10.2.0 + prettier: 3.6.2 + progress: 2.0.3 + semver: 7.7.3 + tmp-promise: 3.0.3 + undici: 7.16.0 + web3-eth-abi: 4.4.1(typescript@5.9.3)(zod@3.25.76) + yaml: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - supports-color + - typescript + - utf-8-validate + - zod + + '@graphprotocol/graph-ts@0.38.2': + dependencies: + assemblyscript: 0.27.31 + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.6.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/confirm@5.1.21(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/core@10.3.2(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.6.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/editor@4.2.23(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/expand@4.0.23(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/number@3.0.23(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/password@4.0.23(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/prompts@7.10.1(@types/node@25.6.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) + '@inquirer/confirm': 5.1.21(@types/node@25.6.0) + '@inquirer/editor': 4.2.23(@types/node@25.6.0) + '@inquirer/expand': 4.0.23(@types/node@25.6.0) + '@inquirer/input': 4.3.1(@types/node@25.6.0) + '@inquirer/number': 3.0.23(@types/node@25.6.0) + '@inquirer/password': 4.0.23(@types/node@25.6.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) + '@inquirer/search': 3.2.2(@types/node@25.6.0) + '@inquirer/select': 4.4.2(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/search@3.2.2(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.6.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/select@4.4.2(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.6.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/type@3.0.10(@types/node@25.6.0)': + optionalDependencies: + '@types/node': 25.6.0 + + '@ipld/dag-cbor@9.2.6': + dependencies: + cborg: 5.1.1 + multiformats: 13.4.2 + + '@ipld/dag-json@10.2.7': + dependencies: + cborg: 5.1.1 + multiformats: 13.4.2 + + '@ipld/dag-pb@4.1.5': + dependencies: + multiformats: 13.4.2 + + '@isaacs/cliui@9.0.0': {} + + '@leichtgewicht/ip-codec@2.0.5': {} + + '@libp2p/crypto@5.1.17': + dependencies: + '@libp2p/interface': 3.2.2 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + multiformats: 13.4.2 + protons-runtime: 6.0.1 + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + '@libp2p/interface@2.11.0': + dependencies: + '@multiformats/dns': 1.0.13 + '@multiformats/multiaddr': 12.5.1 + it-pushable: 3.2.3 + it-stream-types: 2.0.4 + main-event: 1.0.4 + multiformats: 13.4.2 + progress-events: 1.1.0 + uint8arraylist: 2.4.9 + + '@libp2p/interface@3.2.2': + dependencies: + '@multiformats/dns': 1.0.13 + '@multiformats/multiaddr': 13.0.1 + main-event: 1.0.4 + multiformats: 13.4.2 + progress-events: 1.1.0 + uint8arraylist: 2.4.9 + + '@libp2p/logger@5.2.0': + dependencies: + '@libp2p/interface': 2.11.0 + '@multiformats/multiaddr': 12.5.1 + interface-datastore: 8.3.2 + multiformats: 13.4.2 + weald: 1.1.1 + + '@libp2p/peer-id@5.1.9': + dependencies: + '@libp2p/crypto': 5.1.17 + '@libp2p/interface': 2.11.0 + multiformats: 13.4.2 + uint8arrays: 5.1.1 + + '@multiformats/dns@1.0.13': + dependencies: + '@dnsquery/dns-packet': 6.1.1 + '@libp2p/interface': 3.2.2 + hashlru: 2.3.0 + p-queue: 9.2.0 + progress-events: 1.1.0 + uint8arrays: 5.1.1 + + '@multiformats/multiaddr-to-uri@11.0.2': + dependencies: + '@multiformats/multiaddr': 12.5.1 + + '@multiformats/multiaddr@12.5.1': + dependencies: + '@chainsafe/is-ip': 2.1.0 + '@chainsafe/netmask': 2.0.0 + '@multiformats/dns': 1.0.13 + abort-error: 1.0.2 + multiformats: 13.4.2 + uint8-varint: 2.0.4 + uint8arrays: 5.1.1 + + '@multiformats/multiaddr@13.0.1': + dependencies: + '@chainsafe/is-ip': 2.1.0 + multiformats: 13.4.2 + uint8-varint: 2.0.4 + uint8arrays: 5.1.1 + + '@noble/curves@1.4.2': + dependencies: + '@noble/hashes': 1.4.0 + + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@1.4.0': {} + + '@noble/hashes@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oclif/core@4.11.0': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.5 + semver: 7.7.3 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.16 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/core@4.5.5': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 9.0.9 + semver: 7.7.3 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.16 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-autocomplete@3.2.46': + dependencies: + '@oclif/core': 4.5.5 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + transitivePeerDependencies: + - supports-color + + '@oclif/plugin-not-found@3.2.81(@types/node@25.6.0)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@25.6.0) + '@oclif/core': 4.11.0 + ansis: 3.17.0 + fast-levenshtein: 3.0.0 + transitivePeerDependencies: + - '@types/node' + + '@oclif/plugin-warn-if-update-available@3.1.61': + dependencies: + '@oclif/core': 4.5.5 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + http-call: 5.3.0 + lodash: 4.18.1 + registry-auth-token: 5.1.1 + transitivePeerDependencies: + - supports-color + + '@pinax/graph-networks-registry@0.7.1': {} + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.2': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@rescript/std@9.0.0': {} + + '@scure/base@1.1.9': {} + + '@scure/bip32@1.4.0': + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.6.0 + + '@types/node@12.20.55': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/parse-json@4.0.2': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 25.6.0 + + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.5 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.5': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + abitype@0.7.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + typescript: 5.9.3 + optionalDependencies: + zod: 3.25.76 + + abort-error@1.0.2: {} + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@3.17.0: {} + + any-signal@4.2.0: {} + + apisauce@2.1.6(debug@4.4.3): + dependencies: + axios: 0.21.4(debug@4.4.3) + transitivePeerDependencies: + - debug + + app-module-path@2.2.0: {} + + argparse@2.0.1: {} + + assemblyscript@0.19.23: + dependencies: + binaryen: 102.0.0-nightly.20211028 + long: 5.3.2 + source-map-support: 0.5.21 + + assemblyscript@0.27.31: + dependencies: + binaryen: 116.0.0-nightly.20240114 + long: 5.3.2 + + async@3.2.6: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@0.21.4(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + binaryen@102.0.0-nightly.20211028: {} + + binaryen@116.0.0-nightly.20240114: {} + + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + blob-to-it@2.0.12: + dependencies: + browser-readablestream-to-it: 2.0.12 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-readablestream-to-it@2.0.12: {} + + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + + buffer-crc32@0.2.13: {} + + buffer-fill@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + cborg@5.1.1: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chardet@2.1.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.0: + dependencies: + object-assign: 4.1.1 + string-width: 4.2.3 + optionalDependencies: + colors: 1.4.0 + + cli-width@4.1.0: {} + + clone@1.0.4: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colors@1.4.0: {} + + commander@2.20.3: {} + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + content-type@1.0.5: {} + + core-util-is@1.0.3: {} + + cosmiconfig@7.0.1: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dag-jose@5.1.1: + dependencies: + '@ipld/dag-cbor': 9.2.6 + multiformats: 13.1.3 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decompress-tar@4.1.1: + dependencies: + file-type: 5.2.0 + is-stream: 1.1.0 + tar-stream: 1.6.2 + + decompress-tarbz2@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 6.2.0 + is-stream: 1.1.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + + decompress-targz@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 5.2.0 + is-stream: 1.1.0 + + decompress-unzip@4.0.1: + dependencies: + file-type: 3.9.0 + get-stream: 2.3.1 + pify: 2.3.0 + yauzl: 2.10.0 + + decompress@4.2.1: + dependencies: + decompress-tar: 4.1.1 + decompress-tarbz2: 4.1.1 + decompress-targz: 4.1.1 + decompress-unzip: 4.0.1 + graceful-fs: 4.2.11 + make-dir: 1.3.0 + pify: 2.3.0 + strip-dirs: 2.1.0 + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + delay@5.0.0: {} + + docker-compose@1.3.0: + dependencies: + yaml: 2.8.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + ejs@3.1.8: + dependencies: + jake: 10.9.4 + + electron-fetch@1.9.1: + dependencies: + encoding: 0.1.13 + + emoji-regex@8.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.3.6: + dependencies: + ansi-colors: 4.1.3 + + err-code@3.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + ethereum-cryptography@2.2.1: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + + eventemitter3@5.0.4: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + eyes@0.1.8: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-levenshtein@3.0.0: + dependencies: + fastest-levenshtein: 1.0.16 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-type@3.9.0: {} + + file-type@5.2.0: {} + + file-type@6.2.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3(supports-color@8.1.1) + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-constants@1.0.0: {} + + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-jetpack@4.3.1: + dependencies: + minimatch: 3.1.5 + rimraf: 2.7.1 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-iterator@1.0.2: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@2.3.1: + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gluegun@5.2.0(debug@4.4.3): + dependencies: + apisauce: 2.1.6(debug@4.4.3) + app-module-path: 2.2.0 + cli-table3: 0.6.0 + colors: 1.4.0 + cosmiconfig: 7.0.1 + cross-spawn: 7.0.3 + ejs: 3.1.8 + enquirer: 2.3.6 + execa: 5.1.1 + fs-jetpack: 4.3.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.lowercase: 4.3.0 + lodash.lowerfirst: 4.3.1 + lodash.pad: 4.5.1 + lodash.padend: 4.6.1 + lodash.padstart: 4.6.1 + lodash.repeat: 4.1.0 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.trim: 4.18.0 + lodash.trimend: 4.18.0 + lodash.trimstart: 4.5.1 + lodash.uppercase: 4.3.0 + lodash.upperfirst: 4.3.1 + ora: 4.0.2 + pluralize: 8.0.0 + semver: 7.3.5 + which: 2.0.2 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - debug + + gopd@1.2.0: {} + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + graphql-import-node@0.0.5(graphql@16.11.0): + dependencies: + graphql: 16.11.0 + + graphql@16.11.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashlru@2.3.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + http-call@5.3.0: + dependencies: + content-type: 1.0.5 + debug: 4.4.3(supports-color@8.1.1) + is-retry-allowed: 1.2.0 + is-stream: 2.0.1 + parse-json: 4.0.0 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + interface-datastore@8.3.2: + dependencies: + interface-store: 6.0.3 + uint8arrays: 5.1.1 + + interface-store@6.0.3: {} + + ipfs-unixfs@11.2.5: + dependencies: + protons-runtime: 5.6.0 + uint8arraylist: 2.4.9 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-arrayish@0.2.1: {} + + is-callable@1.2.7: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-electron@2.2.2: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-natural-number@4.0.1: {} + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-retry-allowed@1.2.0: {} + + is-stream@1.1.0: {} + + is-stream@2.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iso-url@1.2.1: {} + + isomorphic-ws@4.0.1(ws@7.5.10): + dependencies: + ws: 7.5.10 + + it-all@3.0.11: {} + + it-first@3.0.11: {} + + it-glob@3.0.6: + dependencies: + fast-glob: 3.3.3 + + it-last@3.0.11: {} + + it-map@3.1.6: + dependencies: + it-peekable: 3.0.10 + + it-peekable@3.0.10: {} + + it-pushable@3.2.3: + dependencies: + p-defer: 4.0.1 + + it-stream-types@2.0.4: {} + + it-to-stream@1.0.0: + dependencies: + buffer: 6.0.3 + fast-fifo: 1.3.2 + get-iterator: 1.0.2 + p-defer: 3.0.0 + p-fifo: 1.0.0 + readable-stream: 3.6.2 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jayson@4.2.0: + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.10) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-stringify-safe@5.0.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kubo-rpc-client@5.4.1(undici@7.16.0): + dependencies: + '@ipld/dag-cbor': 9.2.6 + '@ipld/dag-json': 10.2.7 + '@ipld/dag-pb': 4.1.5 + '@libp2p/crypto': 5.1.17 + '@libp2p/interface': 2.11.0 + '@libp2p/logger': 5.2.0 + '@libp2p/peer-id': 5.1.9 + '@multiformats/multiaddr': 12.5.1 + '@multiformats/multiaddr-to-uri': 11.0.2 + any-signal: 4.2.0 + blob-to-it: 2.0.12 + browser-readablestream-to-it: 2.0.12 + dag-jose: 5.1.1 + electron-fetch: 1.9.1 + err-code: 3.0.1 + ipfs-unixfs: 11.2.5 + iso-url: 1.2.1 + it-all: 3.0.11 + it-first: 3.0.11 + it-glob: 3.0.6 + it-last: 3.0.11 + it-map: 3.1.6 + it-peekable: 3.0.10 + it-to-stream: 1.0.0 + merge-options: 3.0.4 + multiformats: 13.4.2 + nanoid: 5.1.11 + native-fetch: 4.0.2(undici@7.16.0) + parse-duration: 2.1.6 + react-native-fetch-api: 3.0.0 + stream-to-it: 1.0.1 + uint8arrays: 5.1.1 + wherearewe: 2.0.1 + transitivePeerDependencies: + - undici + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash.camelcase@4.3.0: {} + + lodash.kebabcase@4.1.1: {} + + lodash.lowercase@4.3.0: {} + + lodash.lowerfirst@4.3.1: {} + + lodash.pad@4.5.1: {} + + lodash.padend@4.6.1: {} + + lodash.padstart@4.6.1: {} + + lodash.repeat@4.1.0: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.trim@4.18.0: {} + + lodash.trimend@4.18.0: {} + + lodash.trimstart@4.5.1: {} + + lodash.uppercase@4.3.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.18.1: {} + + log-symbols@3.0.0: + dependencies: + chalk: 2.4.2 + + long@5.3.2: {} + + lru-cache@11.3.5: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + main-event@1.0.4: {} + + make-dir@1.3.0: + dependencies: + pify: 3.0.0 + + matchstick-as@0.6.0: + dependencies: + wabt: 1.0.24 + + math-intrinsics@1.1.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + ms@3.0.0-canary.202508261828: {} + + multiformats@13.1.3: {} + + multiformats@13.4.2: {} + + mute-stream@2.0.0: {} + + nanoid@5.1.11: {} + + native-fetch@4.0.2(undici@7.16.0): + dependencies: + undici: 7.16.0 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + ora@4.0.2: + dependencies: + chalk: 2.4.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + log-symbols: 3.0.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + p-defer@3.0.0: {} + + p-defer@4.0.1: {} + + p-fifo@1.0.0: + dependencies: + fast-fifo: 1.3.2 + p-defer: 3.0.0 + + p-queue@9.2.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-duration@2.1.6: {} + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.5 + minipass: 7.1.3 + + path-type@4.0.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + + pluralize@8.0.0: {} + + possible-typed-array-names@1.1.0: {} + + prettier@3.6.2: {} + + process-nextick-args@2.0.1: {} + + progress-events@1.1.0: {} + + progress@2.0.3: {} + + proto-list@1.2.4: {} + + protons-runtime@5.6.0: + dependencies: + uint8-varint: 2.0.4 + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + protons-runtime@6.0.1: + dependencies: + uint8-varint: 2.0.4 + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + queue-microtask@1.2.3: {} + + react-native-fetch-api@3.0.0: + dependencies: + p-defer: 3.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.2 + + resolve-from@4.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + seek-bzip@1.0.6: + dependencies: + commander: 2.20.3 + + semver@7.3.5: + dependencies: + lru-cache: 6.0.0 + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + stream-to-it@1.0.1: + dependencies: + it-stream-types: 2.0.4 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-dirs@2.1.0: + dependencies: + is-natural-number: 4.0.1 + + strip-final-newline@2.0.0: {} + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-stream@1.6.2: + dependencies: + bl: 1.2.3 + buffer-alloc: 1.2.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + readable-stream: 2.3.8 + to-buffer: 1.2.2 + xtend: 4.0.2 + + through@2.3.8: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.5 + + tmp@0.2.5: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-fest@0.21.3: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + uint8-varint@2.0.4: + dependencies: + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + uint8arraylist@2.4.9: + dependencies: + uint8arrays: 5.1.1 + + uint8arrays@5.1.1: + dependencies: + multiformats: 13.4.2 + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + undici-types@7.19.2: {} + + undici@7.16.0: {} + + universalify@2.0.1: {} + + urlpattern-polyfill@10.1.0: {} + + utf8-codec@1.0.0: {} + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + + uuid@8.3.2: {} + + wabt@1.0.24: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + weald@1.1.1: + dependencies: + ms: 3.0.0-canary.202508261828 + supports-color: 10.2.2 + + web3-errors@1.3.1: + dependencies: + web3-types: 1.10.0 + + web3-eth-abi@4.4.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + abitype: 0.7.1(typescript@5.9.3)(zod@3.25.76) + web3-errors: 1.3.1 + web3-types: 1.10.0 + web3-utils: 4.3.3 + web3-validator: 2.0.6 + transitivePeerDependencies: + - typescript + - zod + + web3-types@1.10.0: {} + + web3-utils@4.3.3: + dependencies: + ethereum-cryptography: 2.2.1 + eventemitter3: 5.0.4 + web3-errors: 1.3.1 + web3-types: 1.10.0 + web3-validator: 2.0.6 + + web3-validator@2.0.6: + dependencies: + ethereum-cryptography: 2.2.1 + util: 0.12.5 + web3-errors: 1.3.1 + web3-types: 1.10.0 + zod: 3.25.76 + + wherearewe@2.0.1: + dependencies: + is-electron: 2.2.2 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@7.5.10: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xtend@4.0.2: {} + + yallist@4.0.0: {} + + yaml@1.10.3: {} + + yaml@2.8.1: {} + + yargs-parser@21.1.1: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yoctocolors-cjs@2.1.3: {} + + zod@3.25.76: {} diff --git a/indexer/schema.graphql b/indexer/schema.graphql new file mode 100644 index 0000000..2596c4a --- /dev/null +++ b/indexer/schema.graphql @@ -0,0 +1,125 @@ +# ── Vault root entity (singleton, id = "0") ───────────────────────────────── +type Vault @entity(immutable: false) { + "Singleton entity, id is always the literal string \"0\"." + id: String! + + # Contract identity / config + contractAddress: Bytes! + collateralToken: Bytes! + marginEngine: Bytes! + insuranceFundAddress: Bytes! + decimals: Int! + + # Lifetime stats + totalDeposited: BigInt! # cumulative gross deposits (excludes insurance-fund deposits) + totalWithdrawn: BigInt! # cumulative gross withdrawals (excludes insurance-fund withdrawals) + insuranceFundDeposited: BigInt! # cumulative gross insurance-fund deposits + insuranceFundWithdrawn: BigInt! # cumulative gross insurance-fund withdrawals + totalSupply: BigInt! # current outstanding receipt tokens + insuranceFundBalance: BigInt! # current INSURANCE_FUND_ADDR balance + totalUsers: Int! # number of distinct addresses with non-zero historical activity + + # Counters + depositCount: Int! + withdrawalCount: Int! + internalTransferCount: Int! + + # Timestamps + initializedAt: BigInt! + lastUpdatedAt: BigInt! +} + +# ── Per-user account ──────────────────────────────────────────────────────── +type VaultUser @entity(immutable: false) { + id: Bytes! # user address + address: Bytes! + + "Current receipt token balance (mirrors `vault.balanceOf(address)`)." + balance: BigInt! + + "Cumulative gross collateral deposited by/for this user." + totalDeposited: BigInt! + "Cumulative gross collateral withdrawn by/for this user." + totalWithdrawn: BigInt! + + "Signed net of internal transfers (received - sent)." + netInternalIn: BigInt! + "Signed net of internal transfers routed via the perps engine (received - sent)." + netFromPerps: BigInt! + "Signed net of internal transfers routed via the options engine (received - sent)." + netFromOptions: BigInt! + "Signed net of internal transfers routed via any other caller (received - sent)." + netFromOther: BigInt! + + # Counters + depositCount: Int! + withdrawalCount: Int! + + # Relations + deposits: [VaultDeposit!]! @derivedFrom(field: "user") + withdrawals: [VaultWithdrawal!]! @derivedFrom(field: "user") + internalTransfersIn: [VaultInternalTransfer!]! @derivedFrom(field: "to") + internalTransfersOut: [VaultInternalTransfer!]! @derivedFrom(field: "from") + + # Timestamps + createdAt: BigInt! + lastActivityAt: BigInt! +} + +# ── Deposit (`Deposited` event; covers `deposit`, `depositFor`, `depositForPermit`, `depositInsuranceFund`) ─ +type VaultDeposit @entity(immutable: true) { + id: Bytes! # tx hash + log index + user: VaultUser! # receipt-token recipient + sender: Bytes! # account that supplied the underlying collateral (msg.sender) + amount: BigInt! + "True when this deposit credits the insurance-fund vanity address." + isInsuranceFund: Boolean! + + # Metadata + timestamp: BigInt! + blockNumber: BigInt! + transactionHash: Bytes! +} + +# ── Withdrawal (`Withdrawn` event; covers `withdraw`, `withdrawTo`, `withdrawInsuranceFund`) ─ +type VaultWithdrawal @entity(immutable: true) { + id: Bytes! # tx hash + log index + user: VaultUser! # account whose receipt tokens were burned + recipient: Bytes! # who received the underlying collateral + amount: BigInt! + "True when this withdrawal debits the insurance-fund vanity address." + isInsuranceFund: Boolean! + + # Metadata + timestamp: BigInt! + blockNumber: BigInt! + transactionHash: Bytes! +} + +# ── Internal transfer (`Transfer` event with from != 0x0 && to != 0x0) ────── +""" +Bucket for the calling contract that triggered an internal vault transfer. +Resolved from `transaction.to` against the perps/options addresses passed in via +the data source `context` (see subgraph.template.yaml). +""" +enum CallerCategory { + PERPS + OPTIONS + OTHER +} + +type VaultInternalTransfer @entity(immutable: true) { + id: Bytes! # tx hash + log index + from: VaultUser! + to: VaultUser! + amount: BigInt! + "Raw `transaction.to` (the contract whose function was called by the EOA)." + caller: Bytes! + "Bucketed `caller`: PERPS / OPTIONS / OTHER." + callerCategory: CallerCategory! + + # Metadata + timestamp: BigInt! + blockNumber: BigInt! + transactionHash: Bytes! +} diff --git a/indexer/src/ids.ts b/indexer/src/ids.ts new file mode 100644 index 0000000..64f7d13 --- /dev/null +++ b/indexer/src/ids.ts @@ -0,0 +1,6 @@ +import { BigInt, Bytes } from "@graphprotocol/graph-ts"; + +/** Stable per-log identifier: `transactionHash || logIndex` (5-byte i32 suffix). */ +export function createEventId(transactionHash: Bytes, logIndex: BigInt): Bytes { + return transactionHash.concatI32(logIndex.toI32()); +} diff --git a/indexer/src/vault.ts b/indexer/src/vault.ts new file mode 100644 index 0000000..55356dc --- /dev/null +++ b/indexer/src/vault.ts @@ -0,0 +1,350 @@ +import { Address, BigInt, Bytes, dataSource, log } from "@graphprotocol/graph-ts"; +import { + CollateralVault as VaultContract, + Deposited, + Initialized, + InsuranceFundDeposited, + InsuranceFundWithdrawn, + Transfer, + Withdrawn, +} from "../generated/CollateralVault/CollateralVault"; +import { + Vault, + VaultDeposit, + VaultInternalTransfer, + VaultUser, + VaultWithdrawal, +} from "../generated/schema"; +import { createEventId } from "./ids"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const ZERO_ADDRESS = Address.zero(); +// Must mirror CollateralVault.INSURANCE_FUND_ADDR — kept in sync because the +// vault constant is fully deterministic (no init dependency) so we don't have +// to refetch it from the contract on every event. +// The on-chain literal is mixed-case (`0xaAaA…aaAa`) for EIP-55 styling, but +// graph-ts/matchstick can mis-parse non-canonical casing, so we use lowercase. +const INSURANCE_FUND_ADDR = Address.fromString("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + +// CallerCategory enum string values (must match schema.graphql). +const CATEGORY_PERPS = "PERPS"; +const CATEGORY_OPTIONS = "OPTIONS"; +const CATEGORY_OTHER = "OTHER"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Read the perps/options engine addresses from `dataSource.context()`. + * Populated from environment variables via subgraph.template.yaml. Tests must + * call `dataSourceMock.setContext(ctx)` to seed these. + */ +function knownCallers(): Address[] { + const ctx = dataSource.context(); + return [ + Address.fromString(ctx.mustGet("perpsAddress").toString()), + Address.fromString(ctx.mustGet("optionsAddress").toString()), + ]; +} + +function categoryOf(caller: Bytes): string { + const known = knownCallers(); + if (caller.equals(known[0])) return CATEGORY_PERPS; + if (caller.equals(known[1])) return CATEGORY_OPTIONS; + return CATEGORY_OTHER; +} + +function getOrCreateVault(): Vault { + let vault = Vault.load("0"); + if (!vault) { + vault = new Vault("0"); + vault.contractAddress = dataSource.address(); + vault.collateralToken = Bytes.empty(); + vault.marginEngine = Bytes.empty(); + vault.insuranceFundAddress = INSURANCE_FUND_ADDR; + vault.decimals = 0; + vault.totalDeposited = BigInt.zero(); + vault.totalWithdrawn = BigInt.zero(); + vault.insuranceFundDeposited = BigInt.zero(); + vault.insuranceFundWithdrawn = BigInt.zero(); + vault.totalSupply = BigInt.zero(); + vault.insuranceFundBalance = BigInt.zero(); + vault.totalUsers = 0; + vault.depositCount = 0; + vault.withdrawalCount = 0; + vault.internalTransferCount = 0; + vault.initializedAt = BigInt.zero(); + vault.lastUpdatedAt = BigInt.zero(); + // Don't `loadVaultFromContract` here — that read happens lazily via + // `handleInitialized`. Calling it here would fail in matchstick (no mocks). + } + return vault; +} + +function loadVaultFromContract(vault: Vault): void { + const contract = VaultContract.bind(dataSource.address()); + + const collateralToken = contract.try_collateralToken(); + if (!collateralToken.reverted) { + vault.collateralToken = collateralToken.value; + } + + const marginEngine = contract.try_marginEngine(); + if (!marginEngine.reverted) { + vault.marginEngine = marginEngine.value; + } + + const decimals = contract.try_decimals(); + if (!decimals.reverted) { + vault.decimals = decimals.value; + } +} + +/** + * Returns the user entity, creating it on first sight. Pair with `bumpUserCountIfNew` + * when you also need to increment `Vault.totalUsers` — separating the two keeps this + * helper safely callable from contexts that are still mid-flight on the Vault entity. + */ +function getOrCreateVaultUser(address: Address, timestamp: BigInt): VaultUser { + let user = VaultUser.load(address); + if (!user) { + user = new VaultUser(address); + user.address = address; + user.balance = BigInt.zero(); + user.totalDeposited = BigInt.zero(); + user.totalWithdrawn = BigInt.zero(); + user.netInternalIn = BigInt.zero(); + user.netFromPerps = BigInt.zero(); + user.netFromOptions = BigInt.zero(); + user.netFromOther = BigInt.zero(); + user.depositCount = 0; + user.withdrawalCount = 0; + user.createdAt = timestamp; + user.lastActivityAt = timestamp; + } + return user; +} + +function bumpUserCount(vault: Vault, isNewUser: boolean): void { + if (isNewUser) vault.totalUsers++; +} + +/** + * Apply a signed delta to a user's combined `netInternalIn` and the matching + * per-category bucket. `delta` is negative for the sender, positive for the + * receiver. Skips silently if the user entity doesn't exist (shouldn't happen + * in normal flows, but matches the prior null-safe behavior). + */ +function bumpCategoryNet(address: Address, category: string, delta: BigInt): void { + const user = VaultUser.load(address); + if (!user) return; + user.netInternalIn = user.netInternalIn.plus(delta); + if (category == CATEGORY_PERPS) { + user.netFromPerps = user.netFromPerps.plus(delta); + } else if (category == CATEGORY_OPTIONS) { + user.netFromOptions = user.netFromOptions.plus(delta); + } else { + user.netFromOther = user.netFromOther.plus(delta); + } + user.save(); +} + +// ── Lifecycle ─────────────────────────────────────────────────────────────── + +export function handleInitialized(event: Initialized): void { + log.info("CollateralVault initialized: version {}", [event.params.version.toString()]); + + const vault = getOrCreateVault(); + vault.initializedAt = event.block.timestamp; + vault.lastUpdatedAt = event.block.timestamp; + loadVaultFromContract(vault); + vault.save(); +} + +// ── Transfer (the single source of truth for balances) ───────────────────── +// +// CollateralVault inherits from ERC20Upgradeable, but its public ERC20 surface +// (`approve`, `transfer`, `transferFrom`) is hard-disabled. Transfer events +// only ever come from internal `_mint` / `_burn` / `_transfer`: +// - mint (from = 0x0) ⇢ paired with `Deposited` +// - burn (to = 0x0) ⇢ paired with `Withdrawn` +// - internal transfer ⇢ from `internalTransfer` / `internalTransferWithMarginCheck` +// That makes Transfer a complete, lossless balance ledger. +export function handleTransfer(event: Transfer): void { + const from = event.params.from; + const to = event.params.to; + const amount = event.params.value; + + const isMint = from.equals(ZERO_ADDRESS); + const isBurn = to.equals(ZERO_ADDRESS); + const vault = getOrCreateVault(); + + if (!isMint) { + const isNew = VaultUser.load(from) === null; + const fromUser = getOrCreateVaultUser(from, event.block.timestamp); + fromUser.balance = fromUser.balance.minus(amount); + fromUser.lastActivityAt = event.block.timestamp; + fromUser.save(); + bumpUserCount(vault, isNew); + } else { + vault.totalSupply = vault.totalSupply.plus(amount); + } + + if (!isBurn) { + const isNew = VaultUser.load(to) === null; + const toUser = getOrCreateVaultUser(to, event.block.timestamp); + toUser.balance = toUser.balance.plus(amount); + toUser.lastActivityAt = event.block.timestamp; + toUser.save(); + bumpUserCount(vault, isNew); + } else { + vault.totalSupply = vault.totalSupply.minus(amount); + } + + if (from.equals(INSURANCE_FUND_ADDR)) { + vault.insuranceFundBalance = vault.insuranceFundBalance.minus(amount); + } + if (to.equals(INSURANCE_FUND_ADDR)) { + vault.insuranceFundBalance = vault.insuranceFundBalance.plus(amount); + } + + if (!isMint && !isBurn) { + // Internal transfer between two real accounts (PnL settlement, fees, etc.). + // `event.transaction.to` is `Bytes | null`; if null we treat it as OTHER. + const txTo = event.transaction.to; + const callerBytes: Bytes = txTo !== null ? (txTo as Bytes) : Bytes.empty(); + const category = categoryOf(callerBytes); + + const transferId = createEventId(event.transaction.hash, event.logIndex); + const transfer = new VaultInternalTransfer(transferId); + transfer.from = from; + transfer.to = to; + transfer.amount = amount; + transfer.caller = callerBytes; + transfer.callerCategory = category; + transfer.timestamp = event.block.timestamp; + transfer.blockNumber = event.block.number; + transfer.transactionHash = event.transaction.hash; + transfer.save(); + + bumpCategoryNet(from, category, amount.neg()); + bumpCategoryNet(to, category, amount); + + vault.internalTransferCount++; + } + + vault.lastUpdatedAt = event.block.timestamp; + vault.save(); +} + +// ── Deposit ───────────────────────────────────────────────────────────────── + +export function handleDeposited(event: Deposited): void { + const recipient = event.params.user; + const amount = event.params.amount; + const sender = event.params.sender; + const isInsuranceFund = recipient.equals(INSURANCE_FUND_ADDR); + + log.info("Deposited: recipient {} amount {} sender {} insuranceFund {}", [ + recipient.toHexString(), + amount.toString(), + sender.toHexString(), + isInsuranceFund ? "true" : "false", + ]); + + const isNewUser = VaultUser.load(recipient) === null; + const user = getOrCreateVaultUser(recipient, event.block.timestamp); + user.totalDeposited = user.totalDeposited.plus(amount); + user.depositCount++; + user.lastActivityAt = event.block.timestamp; + user.save(); + + const id = createEventId(event.transaction.hash, event.logIndex); + const deposit = new VaultDeposit(id); + deposit.user = user.id; + deposit.sender = sender; + deposit.amount = amount; + deposit.isInsuranceFund = isInsuranceFund; + deposit.timestamp = event.block.timestamp; + deposit.blockNumber = event.block.number; + deposit.transactionHash = event.transaction.hash; + deposit.save(); + + const vault = getOrCreateVault(); + if (!isInsuranceFund) { + vault.totalDeposited = vault.totalDeposited.plus(amount); + vault.depositCount++; + } + bumpUserCount(vault, isNewUser); + vault.lastUpdatedAt = event.block.timestamp; + vault.save(); +} + +// ── Withdraw ──────────────────────────────────────────────────────────────── + +export function handleWithdrawn(event: Withdrawn): void { + const owner = event.params.user; + const amount = event.params.amount; + const recipient = event.params.recipient; + const isInsuranceFund = owner.equals(INSURANCE_FUND_ADDR); + + log.info("Withdrawn: owner {} amount {} recipient {} insuranceFund {}", [ + owner.toHexString(), + amount.toString(), + recipient.toHexString(), + isInsuranceFund ? "true" : "false", + ]); + + const isNewUser = VaultUser.load(owner) === null; + const user = getOrCreateVaultUser(owner, event.block.timestamp); + user.totalWithdrawn = user.totalWithdrawn.plus(amount); + user.withdrawalCount++; + user.lastActivityAt = event.block.timestamp; + user.save(); + + const id = createEventId(event.transaction.hash, event.logIndex); + const withdrawal = new VaultWithdrawal(id); + withdrawal.user = user.id; + withdrawal.recipient = recipient; + withdrawal.amount = amount; + withdrawal.isInsuranceFund = isInsuranceFund; + withdrawal.timestamp = event.block.timestamp; + withdrawal.blockNumber = event.block.number; + withdrawal.transactionHash = event.transaction.hash; + withdrawal.save(); + + const vault = getOrCreateVault(); + if (!isInsuranceFund) { + vault.totalWithdrawn = vault.totalWithdrawn.plus(amount); + vault.withdrawalCount++; + } + bumpUserCount(vault, isNewUser); + vault.lastUpdatedAt = event.block.timestamp; + vault.save(); +} + +// ── Insurance fund (paired markers; primary entities are created via Deposited/Withdrawn) ─ + +export function handleInsuranceFundDeposited(event: InsuranceFundDeposited): void { + log.info("InsuranceFundDeposited: source {} amount {}", [ + event.params.source.toHexString(), + event.params.amount.toString(), + ]); + + const vault = getOrCreateVault(); + vault.insuranceFundDeposited = vault.insuranceFundDeposited.plus(event.params.amount); + vault.lastUpdatedAt = event.block.timestamp; + vault.save(); +} + +export function handleInsuranceFundWithdrawn(event: InsuranceFundWithdrawn): void { + log.info("InsuranceFundWithdrawn: recipient {} amount {}", [ + event.params.recipient.toHexString(), + event.params.amount.toString(), + ]); + + const vault = getOrCreateVault(); + vault.insuranceFundWithdrawn = vault.insuranceFundWithdrawn.plus(event.params.amount); + vault.lastUpdatedAt = event.block.timestamp; + vault.save(); +} diff --git a/indexer/subgraph.template.yaml b/indexer/subgraph.template.yaml new file mode 100644 index 0000000..a08fe8d --- /dev/null +++ b/indexer/subgraph.template.yaml @@ -0,0 +1,51 @@ +# Use subgraph.template.yaml to add changes to the subgraph.yaml file +# Variables are substituted via envsubst from environment variables +specVersion: 1.3.0 +indexerHints: + prune: auto +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum + name: CollateralVault + network: "${NETWORK}" + source: + address: "${VAULT_ADDRESS}" + startBlock: ${VAULT_START_BLOCK} + abi: CollateralVault + context: + # Authorized engine addresses, used to bucket `transaction.to` on + # internal Transfer events into PERPS / OPTIONS / OTHER. + perpsAddress: + type: String + data: "${PERPS_ADDRESS}" + optionsAddress: + type: String + data: "${OPTIONS_ADDRESS}" + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + entities: + - Vault + - VaultUser + - VaultDeposit + - VaultWithdrawal + - VaultInternalTransfer + abis: + - name: CollateralVault + file: ../contracts/abi/CollateralVault.json + eventHandlers: + - event: Initialized(uint64) + handler: handleInitialized + - event: Transfer(indexed address,indexed address,uint256) + handler: handleTransfer + - event: Deposited(indexed address,uint256,indexed address) + handler: handleDeposited + - event: Withdrawn(indexed address,uint256,indexed address) + handler: handleWithdrawn + - event: InsuranceFundDeposited(indexed address,uint256) + handler: handleInsuranceFundDeposited + - event: InsuranceFundWithdrawn(indexed address,uint256) + handler: handleInsuranceFundWithdrawn + file: ./src/vault.ts diff --git a/indexer/tests/deposit.test.ts b/indexer/tests/deposit.test.ts new file mode 100644 index 0000000..eba48eb --- /dev/null +++ b/indexer/tests/deposit.test.ts @@ -0,0 +1,115 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; +import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; +import { Deposited, Transfer } from "../generated/CollateralVault/CollateralVault"; +import { handleDeposited, handleTransfer } from "../src/vault"; +import { + INSURANCE_FUND_ADDRESS, + paramAddr, + paramUint, + setupDataSourceMock, + setupVault, + userAddress, +} from "./helpers"; + +const ZERO = Address.zero(); + +function createDepositedEvent(recipient: Address, amount: BigInt, sender: Address): Deposited { + return newTypedMockEventWithParams([ + paramAddr("user", recipient), + paramUint("amount", amount), + paramAddr("sender", sender), + ]); +} + +function createTransferEvent(from: Address, to: Address, value: BigInt): Transfer { + return newTypedMockEventWithParams([ + paramAddr("from", from), + paramAddr("to", to), + paramUint("value", value), + ]); +} + +describe("handleDeposited", () => { + beforeEach(() => { + clearStore(); + setupDataSourceMock(); + setupVault(); + }); + + test("user deposit creates VaultDeposit, bumps user totals, bumps vault totals", () => { + const alice = userAddress(1); + const amount = BigInt.fromI32(1_000_000); + + // Real chain order: mint Transfer first, then Deposited. + handleTransfer(createTransferEvent(ZERO, alice, amount)); + const evt = createDepositedEvent(alice, amount, alice); + handleDeposited(evt); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.entityCount("VaultDeposit", 1); + assert.fieldEquals("VaultDeposit", id, "user", alice.toHexString()); + assert.fieldEquals("VaultDeposit", id, "sender", alice.toHexString()); + assert.fieldEquals("VaultDeposit", id, "amount", amount.toString()); + assert.fieldEquals("VaultDeposit", id, "isInsuranceFund", "false"); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", amount.toString()); + assert.fieldEquals("VaultUser", alice.toHexString(), "totalDeposited", amount.toString()); + assert.fieldEquals("VaultUser", alice.toHexString(), "depositCount", "1"); + + assert.fieldEquals("Vault", "0", "totalDeposited", amount.toString()); + assert.fieldEquals("Vault", "0", "depositCount", "1"); + assert.fieldEquals("Vault", "0", "totalSupply", amount.toString()); + }); + + test("depositFor: receipt mints to recipient, sender field tracks the funder", () => { + const alice = userAddress(1); // funder + const bob = userAddress(2); // receipt recipient + const amount = BigInt.fromI32(500_000); + + handleTransfer(createTransferEvent(ZERO, bob, amount)); + handleDeposited(createDepositedEvent(bob, amount, alice)); + + assert.fieldEquals("VaultUser", bob.toHexString(), "balance", amount.toString()); + assert.fieldEquals("VaultUser", bob.toHexString(), "totalDeposited", amount.toString()); + // Alice never received receipt tokens, so no VaultUser is created for her — + // her identity is captured in the deposit entity's `sender` field. + assert.notInStore("VaultUser", alice.toHexString()); + assert.entityCount("VaultDeposit", 1); + + // Only one VaultUser exists (bob); alice was just the funder. + assert.entityCount("VaultUser", 1); + }); + + test("insurance-fund deposit is excluded from Vault.totalDeposited", () => { + const treasury = userAddress(7); + const ifAddr = INSURANCE_FUND_ADDRESS; + const amount = BigInt.fromI32(2_500_000); + + handleTransfer(createTransferEvent(ZERO, ifAddr, amount)); + const evt = createDepositedEvent(ifAddr, amount, treasury); + handleDeposited(evt); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("VaultDeposit", id, "isInsuranceFund", "true"); + + // Insurance-fund "user" still gets credited (useful for queryability). + assert.fieldEquals( + "VaultUser", + INSURANCE_FUND_ADDRESS.toHexString(), + "totalDeposited", + amount.toString(), + ); + assert.fieldEquals( + "VaultUser", + INSURANCE_FUND_ADDRESS.toHexString(), + "balance", + amount.toString(), + ); + + // ...but Vault aggregates exclude insurance-fund flow. + assert.fieldEquals("Vault", "0", "totalDeposited", "0"); + assert.fieldEquals("Vault", "0", "depositCount", "0"); + assert.fieldEquals("Vault", "0", "insuranceFundBalance", amount.toString()); + }); +}); diff --git a/indexer/tests/helpers.ts b/indexer/tests/helpers.ts new file mode 100644 index 0000000..bd533d4 --- /dev/null +++ b/indexer/tests/helpers.ts @@ -0,0 +1,90 @@ +/** + * Deterministic test data generators and event param helpers. + * AssemblyScript has no Math.random, so we use seeds for reproducible, meaningful IDs. + */ +import { Address, BigInt, Bytes, DataSourceContext, ethereum, Value } from "@graphprotocol/graph-ts"; +import { dataSourceMock } from "matchstick-as/assembly/index"; +import { Vault } from "../generated/schema"; + +function padLeft(s: string, len: i32, char: string): string { + while (s.length < len) { + s = char + s; + } + return s; +} + +/** Deterministic address from numeric id. e.g. userAddress(1) => 0x00...01 */ +export function userAddress(id: i32): Address { + const hex = padLeft(id.toString(16), 40, "0"); + return Address.fromString("0x" + hex); +} + +export const VAULT_ADDRESS = userAddress(255); +// Stand-in engine addresses used for the data-source `context` in tests; mirror +// what subgraph.template.yaml injects from the env in production. +export const PERPS_ADDRESS = userAddress(101); +export const OPTIONS_ADDRESS = userAddress(102); + +/** + * Mock both `dataSource.address()` and `dataSource.context()` so handlers can + * resolve the perps/options addresses passed in by the manifest's `context` + * block. Call from every `beforeEach`. + */ +export function setupDataSourceMock(): void { + const ctx = new DataSourceContext(); + ctx.set("perpsAddress", Value.fromString(PERPS_ADDRESS.toHexString())); + ctx.set("optionsAddress", Value.fromString(OPTIONS_ADDRESS.toHexString())); + dataSourceMock.setAddressAndContext(VAULT_ADDRESS.toHexString(), ctx); +} + +export const INSURANCE_FUND_ADDRESS = Address.fromString( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); + +// ── ethereum.EventParam helpers ───────────────────────────────────────────── + +export function paramAddr(name: string, value: Address): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromAddress(value)); +} + +export function paramUint(name: string, value: BigInt): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(value)); +} + +export function paramInt64(name: string, value: i64): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(BigInt.fromI64(value))); +} + +export function paramBool(name: string, value: boolean): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromBoolean(value)); +} + +export function paramBytes(name: string, value: Bytes): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromBytes(value)); +} + +/** + * Pre-create the Vault singleton so handlers don't trigger `loadVaultFromContract`, + * which would attempt unmocked contract calls. Matchstick can't execute those. + */ +export function setupVault(): void { + const vault = new Vault("0"); + vault.contractAddress = changetype(VAULT_ADDRESS); + vault.collateralToken = Bytes.empty(); + vault.marginEngine = Bytes.empty(); + vault.insuranceFundAddress = Bytes.empty(); + vault.decimals = 6; + vault.totalDeposited = BigInt.zero(); + vault.totalWithdrawn = BigInt.zero(); + vault.insuranceFundDeposited = BigInt.zero(); + vault.insuranceFundWithdrawn = BigInt.zero(); + vault.totalSupply = BigInt.zero(); + vault.insuranceFundBalance = BigInt.zero(); + vault.totalUsers = 0; + vault.depositCount = 0; + vault.withdrawalCount = 0; + vault.internalTransferCount = 0; + vault.initializedAt = BigInt.zero(); + vault.lastUpdatedAt = BigInt.zero(); + vault.save(); +} diff --git a/indexer/tests/insurance-fund.test.ts b/indexer/tests/insurance-fund.test.ts new file mode 100644 index 0000000..d388b89 --- /dev/null +++ b/indexer/tests/insurance-fund.test.ts @@ -0,0 +1,57 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; +import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; +import { + InsuranceFundDeposited, + InsuranceFundWithdrawn, +} from "../generated/CollateralVault/CollateralVault"; +import { + handleInsuranceFundDeposited, + handleInsuranceFundWithdrawn, +} from "../src/vault"; +import { paramAddr, paramUint, setupDataSourceMock, setupVault, userAddress } from "./helpers"; + +function createInsuranceFundDepositedEvent( + source: Address, + amount: BigInt, +): InsuranceFundDeposited { + return newTypedMockEventWithParams([ + paramAddr("source", source), + paramUint("amount", amount), + ]); +} + +function createInsuranceFundWithdrawnEvent( + recipient: Address, + amount: BigInt, +): InsuranceFundWithdrawn { + return newTypedMockEventWithParams([ + paramAddr("recipient", recipient), + paramUint("amount", amount), + ]); +} + +describe("insurance fund marker handlers", () => { + beforeEach(() => { + clearStore(); + setupDataSourceMock(); + setupVault(); + }); + + test("InsuranceFundDeposited bumps Vault.insuranceFundDeposited", () => { + const treasury = userAddress(7); + handleInsuranceFundDeposited(createInsuranceFundDepositedEvent(treasury, BigInt.fromI32(1000))); + handleInsuranceFundDeposited(createInsuranceFundDepositedEvent(treasury, BigInt.fromI32(2500))); + + assert.fieldEquals("Vault", "0", "insuranceFundDeposited", "3500"); + assert.fieldEquals("Vault", "0", "insuranceFundWithdrawn", "0"); + }); + + test("InsuranceFundWithdrawn bumps Vault.insuranceFundWithdrawn", () => { + const treasury = userAddress(7); + handleInsuranceFundWithdrawn(createInsuranceFundWithdrawnEvent(treasury, BigInt.fromI32(800))); + + assert.fieldEquals("Vault", "0", "insuranceFundWithdrawn", "800"); + assert.fieldEquals("Vault", "0", "insuranceFundDeposited", "0"); + }); +}); diff --git a/indexer/tests/transfer.test.ts b/indexer/tests/transfer.test.ts new file mode 100644 index 0000000..d6ba4f7 --- /dev/null +++ b/indexer/tests/transfer.test.ts @@ -0,0 +1,141 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; +import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; +import { Transfer } from "../generated/CollateralVault/CollateralVault"; +import { handleTransfer } from "../src/vault"; +import { + INSURANCE_FUND_ADDRESS, + OPTIONS_ADDRESS, + PERPS_ADDRESS, + paramAddr, + paramUint, + setupDataSourceMock, + setupVault, + userAddress, +} from "./helpers"; + +const ZERO = Address.zero(); + +function createTransferEvent( + from: Address, + to: Address, + value: BigInt, + callerAddress: Address = ZERO, +): Transfer { + const event = newTypedMockEventWithParams([ + paramAddr("from", from), + paramAddr("to", to), + paramUint("value", value), + ]); + if (!callerAddress.equals(ZERO)) { + event.transaction.to = callerAddress; + } + return event; +} + +describe("handleTransfer", () => { + beforeEach(() => { + clearStore(); + setupDataSourceMock(); + setupVault(); + }); + + test("mint credits balance and grows totalSupply", () => { + const alice = userAddress(1); + const event = createTransferEvent(ZERO, alice, BigInt.fromI32(1_000_000)); + + handleTransfer(event); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "1000000"); + assert.fieldEquals("Vault", "0", "totalSupply", "1000000"); + assert.fieldEquals("Vault", "0", "totalUsers", "1"); + assert.entityCount("VaultInternalTransfer", 0); + }); + + test("burn debits balance and shrinks totalSupply", () => { + const alice = userAddress(1); + handleTransfer(createTransferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + handleTransfer(createTransferEvent(alice, ZERO, BigInt.fromI32(400_000))); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "600000"); + assert.fieldEquals("Vault", "0", "totalSupply", "600000"); + assert.entityCount("VaultInternalTransfer", 0); + }); + + test("internal transfer between real accounts moves balance and creates VaultInternalTransfer", () => { + const alice = userAddress(1); + const bob = userAddress(2); + handleTransfer(createTransferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + + const transferEvt = createTransferEvent(alice, bob, BigInt.fromI32(250_000)); + handleTransfer(transferEvt); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "750000"); + assert.fieldEquals("VaultUser", bob.toHexString(), "balance", "250000"); + assert.fieldEquals("VaultUser", alice.toHexString(), "netInternalIn", "-250000"); + assert.fieldEquals("VaultUser", bob.toHexString(), "netInternalIn", "250000"); + assert.fieldEquals("Vault", "0", "totalSupply", "1000000"); + assert.fieldEquals("Vault", "0", "internalTransferCount", "1"); + assert.entityCount("VaultInternalTransfer", 1); + + const id = transferEvt.transaction.hash.concatI32(transferEvt.logIndex.toI32()).toHexString(); + assert.fieldEquals("VaultInternalTransfer", id, "from", alice.toHexString()); + assert.fieldEquals("VaultInternalTransfer", id, "to", bob.toHexString()); + assert.fieldEquals("VaultInternalTransfer", id, "amount", "250000"); + // No transaction.to set ⇒ falls through to OTHER. + assert.fieldEquals("VaultInternalTransfer", id, "callerCategory", "OTHER"); + }); + + test("internal transfer attributes callerCategory by transaction.to", () => { + const alice = userAddress(1); + const bob = userAddress(2); + handleTransfer(createTransferEvent(ZERO, alice, BigInt.fromI32(2_000_000))); + + // PERPS-routed transfer + const perpsCall = createTransferEvent(alice, bob, BigInt.fromI32(100_000), PERPS_ADDRESS); + perpsCall.logIndex = BigInt.fromI32(10); + handleTransfer(perpsCall); + + // OPTIONS-routed transfer + const optionsCall = createTransferEvent(bob, alice, BigInt.fromI32(70_000), OPTIONS_ADDRESS); + optionsCall.logIndex = BigInt.fromI32(11); + handleTransfer(optionsCall); + + const perpsId = perpsCall.transaction.hash.concatI32(10).toHexString(); + const optionsId = optionsCall.transaction.hash.concatI32(11).toHexString(); + assert.fieldEquals("VaultInternalTransfer", perpsId, "callerCategory", "PERPS"); + assert.fieldEquals("VaultInternalTransfer", optionsId, "callerCategory", "OPTIONS"); + + // Per-user signed nets per category + assert.fieldEquals("VaultUser", alice.toHexString(), "netFromPerps", "-100000"); + assert.fieldEquals("VaultUser", alice.toHexString(), "netFromOptions", "70000"); + assert.fieldEquals("VaultUser", bob.toHexString(), "netFromPerps", "100000"); + assert.fieldEquals("VaultUser", bob.toHexString(), "netFromOptions", "-70000"); + + // Combined net is the sum. + assert.fieldEquals("VaultUser", alice.toHexString(), "netInternalIn", "-30000"); + assert.fieldEquals("VaultUser", bob.toHexString(), "netInternalIn", "30000"); + }); + + test("transfers touching the insurance fund update Vault.insuranceFundBalance", () => { + const alice = userAddress(1); + handleTransfer(createTransferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + + // Alice → insurance fund (e.g. liquidation penalty). Routed through perps. + handleTransfer( + createTransferEvent(alice, INSURANCE_FUND_ADDRESS, BigInt.fromI32(300_000), PERPS_ADDRESS), + ); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "700000"); + assert.fieldEquals("VaultUser", INSURANCE_FUND_ADDRESS.toHexString(), "balance", "300000"); + assert.fieldEquals("Vault", "0", "insuranceFundBalance", "300000"); + + // Insurance fund pays a recovery back to alice. + handleTransfer( + createTransferEvent(INSURANCE_FUND_ADDRESS, alice, BigInt.fromI32(50_000), PERPS_ADDRESS), + ); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "750000"); + assert.fieldEquals("Vault", "0", "insuranceFundBalance", "250000"); + }); +}); diff --git a/indexer/tests/withdraw.test.ts b/indexer/tests/withdraw.test.ts new file mode 100644 index 0000000..af35db7 --- /dev/null +++ b/indexer/tests/withdraw.test.ts @@ -0,0 +1,108 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; +import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; +import { Transfer, Withdrawn } from "../generated/CollateralVault/CollateralVault"; +import { handleTransfer, handleWithdrawn } from "../src/vault"; +import { + INSURANCE_FUND_ADDRESS, + paramAddr, + paramUint, + setupDataSourceMock, + setupVault, + userAddress, +} from "./helpers"; + +const ZERO = Address.zero(); + +function createWithdrawnEvent(owner: Address, amount: BigInt, recipient: Address): Withdrawn { + return newTypedMockEventWithParams([ + paramAddr("user", owner), + paramUint("amount", amount), + paramAddr("recipient", recipient), + ]); +} + +function createTransferEvent(from: Address, to: Address, value: BigInt): Transfer { + return newTypedMockEventWithParams([ + paramAddr("from", from), + paramAddr("to", to), + paramUint("value", value), + ]); +} + +describe("handleWithdrawn", () => { + beforeEach(() => { + clearStore(); + setupDataSourceMock(); + setupVault(); + }); + + test("user withdraw creates VaultWithdrawal, bumps user totals, bumps vault totals", () => { + const alice = userAddress(1); + const deposit = BigInt.fromI32(1_000_000); + const withdrawal = BigInt.fromI32(400_000); + + // Seed balance: deposit then withdraw. + handleTransfer(createTransferEvent(ZERO, alice, deposit)); + handleTransfer(createTransferEvent(alice, ZERO, withdrawal)); + const evt = createWithdrawnEvent(alice, withdrawal, alice); + handleWithdrawn(evt); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.entityCount("VaultWithdrawal", 1); + assert.fieldEquals("VaultWithdrawal", id, "user", alice.toHexString()); + assert.fieldEquals("VaultWithdrawal", id, "recipient", alice.toHexString()); + assert.fieldEquals("VaultWithdrawal", id, "amount", withdrawal.toString()); + assert.fieldEquals("VaultWithdrawal", id, "isInsuranceFund", "false"); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "600000"); + assert.fieldEquals("VaultUser", alice.toHexString(), "totalWithdrawn", withdrawal.toString()); + assert.fieldEquals("VaultUser", alice.toHexString(), "withdrawalCount", "1"); + + assert.fieldEquals("Vault", "0", "totalWithdrawn", withdrawal.toString()); + assert.fieldEquals("Vault", "0", "withdrawalCount", "1"); + assert.fieldEquals("Vault", "0", "totalSupply", "600000"); + }); + + test("withdrawTo: balance burned from owner, recipient field tracks the recipient", () => { + const alice = userAddress(1); // owner / authorized caller + const bob = userAddress(2); // recipient + const amount = BigInt.fromI32(500_000); + + handleTransfer(createTransferEvent(ZERO, alice, amount)); + handleTransfer(createTransferEvent(alice, ZERO, amount)); + handleWithdrawn(createWithdrawnEvent(alice, amount, bob)); + + assert.fieldEquals("VaultUser", alice.toHexString(), "balance", "0"); + assert.fieldEquals("VaultUser", alice.toHexString(), "totalWithdrawn", amount.toString()); + assert.entityCount("VaultWithdrawal", 1); + }); + + test("insurance-fund withdraw is excluded from Vault.totalWithdrawn", () => { + const treasury = userAddress(7); + const amount = BigInt.fromI32(2_500_000); + + // Seed insurance-fund balance. + handleTransfer(createTransferEvent(ZERO, INSURANCE_FUND_ADDRESS, amount)); + handleTransfer(createTransferEvent(INSURANCE_FUND_ADDRESS, ZERO, amount)); + const evt = createWithdrawnEvent(INSURANCE_FUND_ADDRESS, amount, treasury); + handleWithdrawn(evt); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("VaultWithdrawal", id, "isInsuranceFund", "true"); + + // VaultUser for the IF address tracks the gross withdrawal. + assert.fieldEquals( + "VaultUser", + INSURANCE_FUND_ADDRESS.toHexString(), + "totalWithdrawn", + amount.toString(), + ); + assert.fieldEquals("VaultUser", INSURANCE_FUND_ADDRESS.toHexString(), "balance", "0"); + + // ...but Vault aggregates exclude it. + assert.fieldEquals("Vault", "0", "totalWithdrawn", "0"); + assert.fieldEquals("Vault", "0", "withdrawalCount", "0"); + assert.fieldEquals("Vault", "0", "insuranceFundBalance", "0"); + }); +}); diff --git a/indexer/tsconfig.json b/indexer/tsconfig.json new file mode 100644 index 0000000..2a368e9 --- /dev/null +++ b/indexer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noEmitOnError": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "target": "esnext", + "module": "commonjs", + "noLib": true, + "allowJs": false, + "skipLibCheck": true, + "typeRoots": ["./node_modules/assemblyscript/std/types"], + "types": ["assembly"], + "paths": { + "*": ["./node_modules/assemblyscript/std/types/assembly/*"] + } + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "generated", "build"] +} diff --git a/indexer/types/ambient.d.ts b/indexer/types/ambient.d.ts new file mode 100644 index 0000000..5147519 --- /dev/null +++ b/indexer/types/ambient.d.ts @@ -0,0 +1,9 @@ +// Stubs for TS-only globals that aren't defined by `assemblyscript/std/types/assembly`. +// Required because we run with `noLib: true` (AssemblyScript types own the global namespace); +// without these, the IDE/tsc emits TS2318 ("Cannot find global type ...") whenever code touches +// function-typed values (e.g. matchstick-as test helpers, graph-ts callbacks). +// +// Pure type-space additions — AssemblyScript's compiler ignores `.d.ts` files. + +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} From 66dea8233f865ed5a2fb164fdd2529d2f7543951 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 13:26:54 +0200 Subject: [PATCH 004/180] initial mm --- market-maker/.DS_Store | Bin 0 -> 8196 bytes market-maker/.cicd_trigger | 1 + market-maker/.dockerignore | 7 + market-maker/Dockerfile | 28 + market-maker/README.md | 279 +++ market-maker/configs/futures.yml | 80 + market-maker/configs/perps.yml | 67 + market-maker/package.json | 33 + market-maker/pnpm-lock.yaml | 510 ++++ market-maker/src/adapter.ts | 144 ++ market-maker/src/adapters/futures/abi.ts | 221 ++ market-maker/src/adapters/futures/factory.ts | 508 ++++ market-maker/src/adapters/futures/index.ts | 5 + .../src/adapters/options-stub/index.ts | 77 + market-maker/src/adapters/perps/abi.ts | 2207 +++++++++++++++++ market-maker/src/adapters/perps/collateral.ts | 117 + market-maker/src/adapters/perps/factory.ts | 331 +++ market-maker/src/adapters/perps/index.ts | 6 + market-maker/src/bookTracker.ts | 135 + market-maker/src/client.ts | 64 + market-maker/src/config.ts | 200 ++ market-maker/src/errSerializer.ts | 38 + market-maker/src/errors.ts | 19 + market-maker/src/gasTracker.ts | 161 ++ market-maker/src/healthcheck.ts | 207 ++ market-maker/src/helpers.ts | 166 ++ market-maker/src/index.ts | 36 + market-maker/src/inventoryManager.ts | 102 + market-maker/src/main.ts | 305 +++ market-maker/src/math.ts | 151 ++ market-maker/src/oracleTracker.ts | 38 + market-maker/src/orderExecutor.ts | 250 ++ market-maker/src/pricing/effectiveSpread.ts | 114 + market-maker/src/pricing/reservationPrice.ts | 111 + market-maker/src/quoter.ts | 153 ++ market-maker/src/rational.ts | 190 ++ market-maker/src/registry.ts | 38 + market-maker/src/riskManager.ts | 166 ++ market-maker/src/sizing/geometricTaper.ts | 36 + market-maker/src/sizing/linear.ts | 11 + market-maker/src/wallet.ts | 46 + .../tests-pending/bookTracker.test.ts | 305 +++ market-maker/tests-pending/client.test.ts | 87 + market-maker/tests-pending/config.test.ts | 127 + .../tests-pending/healthcheck.test.ts | 346 +++ market-maker/tests-pending/helpers.ts | 59 + .../tests-pending/inventoryManager.test.ts | 156 ++ .../tests-pending/market-maker.e2e.test.ts | 535 ++++ .../market-maker.process.test.ts | 655 +++++ .../tests-pending/orderExecutor.test.ts | 677 +++++ market-maker/tests-pending/quoter.test.ts | 335 +++ market-maker/tests/config.test.ts | 198 ++ market-maker/tests/errSerializer.test.ts | 62 + market-maker/tests/gasTracker.test.ts | 141 ++ market-maker/tests/geometricTaper.test.ts | 75 + market-maker/tests/helpers.test.ts | 167 ++ market-maker/tests/math.test.ts | 132 + market-maker/tests/oracleTracker.test.ts | 62 + market-maker/tests/realizedVolatility.test.ts | 115 + market-maker/tests/reservationPrice.test.ts | 171 ++ market-maker/tests/riskManager.test.ts | 181 ++ market-maker/tsconfig.json | 16 + 62 files changed, 11960 insertions(+) create mode 100644 market-maker/.DS_Store create mode 100644 market-maker/.cicd_trigger create mode 100644 market-maker/.dockerignore create mode 100644 market-maker/Dockerfile create mode 100644 market-maker/README.md create mode 100644 market-maker/configs/futures.yml create mode 100644 market-maker/configs/perps.yml create mode 100644 market-maker/package.json create mode 100644 market-maker/pnpm-lock.yaml create mode 100644 market-maker/src/adapter.ts create mode 100644 market-maker/src/adapters/futures/abi.ts create mode 100644 market-maker/src/adapters/futures/factory.ts create mode 100644 market-maker/src/adapters/futures/index.ts create mode 100644 market-maker/src/adapters/options-stub/index.ts create mode 100644 market-maker/src/adapters/perps/abi.ts create mode 100644 market-maker/src/adapters/perps/collateral.ts create mode 100644 market-maker/src/adapters/perps/factory.ts create mode 100644 market-maker/src/adapters/perps/index.ts create mode 100644 market-maker/src/bookTracker.ts create mode 100644 market-maker/src/client.ts create mode 100644 market-maker/src/config.ts create mode 100644 market-maker/src/errSerializer.ts create mode 100644 market-maker/src/errors.ts create mode 100644 market-maker/src/gasTracker.ts create mode 100644 market-maker/src/healthcheck.ts create mode 100644 market-maker/src/helpers.ts create mode 100644 market-maker/src/index.ts create mode 100644 market-maker/src/inventoryManager.ts create mode 100644 market-maker/src/main.ts create mode 100644 market-maker/src/math.ts create mode 100644 market-maker/src/oracleTracker.ts create mode 100644 market-maker/src/orderExecutor.ts create mode 100644 market-maker/src/pricing/effectiveSpread.ts create mode 100644 market-maker/src/pricing/reservationPrice.ts create mode 100644 market-maker/src/quoter.ts create mode 100644 market-maker/src/rational.ts create mode 100644 market-maker/src/registry.ts create mode 100644 market-maker/src/riskManager.ts create mode 100644 market-maker/src/sizing/geometricTaper.ts create mode 100644 market-maker/src/sizing/linear.ts create mode 100644 market-maker/src/wallet.ts create mode 100644 market-maker/tests-pending/bookTracker.test.ts create mode 100644 market-maker/tests-pending/client.test.ts create mode 100644 market-maker/tests-pending/config.test.ts create mode 100644 market-maker/tests-pending/healthcheck.test.ts create mode 100644 market-maker/tests-pending/helpers.ts create mode 100644 market-maker/tests-pending/inventoryManager.test.ts create mode 100644 market-maker/tests-pending/market-maker.e2e.test.ts create mode 100644 market-maker/tests-pending/market-maker.process.test.ts create mode 100644 market-maker/tests-pending/orderExecutor.test.ts create mode 100644 market-maker/tests-pending/quoter.test.ts create mode 100644 market-maker/tests/config.test.ts create mode 100644 market-maker/tests/errSerializer.test.ts create mode 100644 market-maker/tests/gasTracker.test.ts create mode 100644 market-maker/tests/geometricTaper.test.ts create mode 100644 market-maker/tests/helpers.test.ts create mode 100644 market-maker/tests/math.test.ts create mode 100644 market-maker/tests/oracleTracker.test.ts create mode 100644 market-maker/tests/realizedVolatility.test.ts create mode 100644 market-maker/tests/reservationPrice.test.ts create mode 100644 market-maker/tests/riskManager.test.ts create mode 100644 market-maker/tsconfig.json diff --git a/market-maker/.DS_Store b/market-maker/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..33714985511d6b8607ea4b98ac1f82af748f4b10 GIT binary patch literal 8196 zcmeHMU2GIp6u#eVDR*ROr?oCErRzeaXsOT!G{sQa{!`jov|G9@MBJZgJ2IWAJF{EB zml9+A_ra*INYED_&?xGI!M_J%VvJ%M6NnG`pqTh%;tMavbLY+irS#1h0&|mlzq#k0 zb7t?E@664aWsIS#kZWVCmNCYu3)I)paDyh+MPJsGKoTJY*|TDI#&R-@MVd2n%d|s5 z7=bVXVFbbmgb@fMa5F@J&TP^63g^CD4aYD7VFYf=2#EJXoVvh7fD01+R|gG#2|!pz zV!zR~E-nb;0VV=mkVq@kAWaFSDS{FM(wyY+Kra#Cf<$S~5ELH>?u?*9fq!@MANSQ6 z5)#8Pj6fKHXjTVSxQa1PTuSGogLQof@?fxkT0`n^pHn#RW0r|GAG=)2aLQ+*qCUQ`{QRS z)@|6hxq17}T^(J0`yN@W)T^rRtkI&AX3nrZbILUGWAkRto0&+vS;Nkm_Vjqsbj^%q zB<=L9QH2(+E>`PRm0F!v&-F&0DrC&33Z{3=mque+T-86~hjR2|hMk=7n3MW3?H^?60sT+ z&GOQZqgqpv2Poe14XlZ^(|M9)Q_Nu}*vsrBJIl_o&)9eDC-y75#QtKJ**^djM4(|U zDxsqeo6v~OXu=NcMh70oK8zxX0~p647?{EgOxT#mQ5?t9IDr@O5?;ZpcoQe_7Ea+^ ze2BC77@y#4e2*V+9zWwZ{EkbwtSCxEDN`zxbxO0cRk>f;rfgT*l}@Ek>6gD;3632$ zv2;gDiso1IXHI_qQch>)ZkRv)d;14sgExKtT&R#4b60d-T%=KZN9Ps3NqUwTc`eg@ zaxX~m!1ie260MkLaI zMd|bGBKw8?!Tx5KQI0i454PUCI7gEM#!@8bh}igWlJU*Jo8g>SKl3%Gb4afg-?7k^$Q zZgDhkTaGY@p~1&O{14SqRJ=$GS!SN<@h>o}!}H&#-)%em E3;Es9JOBUy literal 0 HcmV?d00001 diff --git a/market-maker/.cicd_trigger b/market-maker/.cicd_trigger new file mode 100644 index 0000000..df26afb --- /dev/null +++ b/market-maker/.cicd_trigger @@ -0,0 +1 @@ +17744633302 diff --git a/market-maker/.dockerignore b/market-maker/.dockerignore new file mode 100644 index 0000000..8fec7ea --- /dev/null +++ b/market-maker/.dockerignore @@ -0,0 +1,7 @@ +node_modules +tests +Dockerfile +.dockerignore +.git +.env +*.md diff --git a/market-maker/Dockerfile b/market-maker/Dockerfile new file mode 100644 index 0000000..f268867 --- /dev/null +++ b/market-maker/Dockerfile @@ -0,0 +1,28 @@ +# ── Install deps ────────────────────────────────────────────────────────────── +FROM node:22-alpine AS deps + +WORKDIR /app + +RUN corepack enable + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +# ── Runtime ─────────────────────────────────────────────────────────────────── +FROM node:22-alpine + +ARG COMMIT_HASH="" +ENV NODE_ENV=production +ENV COMMIT_HASH=$COMMIT_HASH + +WORKDIR /app + +COPY --from=deps /app/node_modules node_modules/ +COPY package.json tsconfig.json ./ +COPY src/ src/ +COPY configs/ configs/ + +RUN addgroup -S maker && adduser -S maker -G maker +USER maker + +CMD ["node", "src/main.ts"] diff --git a/market-maker/README.md b/market-maker/README.md new file mode 100644 index 0000000..8a83ea7 --- /dev/null +++ b/market-maker/README.md @@ -0,0 +1,279 @@ +# Perps Market Maker + +Automated market maker for the HashPowerPerpsDEX on-chain CLOB. Provides two-sided liquidity by placing layered limit orders around the oracle price, dynamically adjusting quotes based on inventory, volatility, and gas conditions. + +## Architecture + +The bot runs a single poll loop (`tick`) that reads on-chain state, computes desired quotes, and reconciles them against resting orders. + +```mermaid +graph LR + subgraph On-chain + CLOB[HashPowerPerpsDEX CLOB] + end + + subgraph State readers + OT[OracleTracker] + GT[GasTracker] + BT[BookTracker] + IM[InventoryManager] + end + + OT -- price, volatility --> Q[Quoter] + GT -- gas price, spike % --> Q + IM -- skew, utilization --> Q + GT -- gas budget --> RM[RiskManager] + IM -- collateral, position --> RM + RM -- allowed sides, halt --> Q + Q -- desired bids & asks --> OE[OrderExecutor] + OE -- cancel / place --> CLOB + BT -- own orders --> OE + CLOB -. events .-> BT + OE -- gas cost --> RM + OE -- stats --> HC[HealthCheck] + + HC -. "GET /health" .-> Mon[Monitoring] +``` + +### Components + +| Component | File | Role | +|---|---|---| +| **OracleTracker** | `oracleTracker.ts` | Reads `getMarketPrice()` each tick; tracks rolling volatility | +| **GasTracker** | `gasTracker.ts` | Reads gas price, detects spikes, estimates tx costs in USD via ETH price feed | +| **BookTracker** | `bookTracker.ts` | Maintains local mirror of order book via `getOrderBookPrices()` + event watching; tracks own orders | +| **InventoryManager** | `inventoryManager.ts` | Reads `getUserPosition()`, `balanceOf()`, `getMaintenanceMargin()` to track net exposure and utilization | +| **RiskManager** | `riskManager.ts` | Drawdown circuit breaker, daily loss limit, gas budget throttling, position limit enforcement | +| **Quoter** | `quoter.ts` | Computes bid/ask levels: Avellaneda-Stoikov inspired spreads with gas floor, volatility scaling, inventory skew | +| **OrderExecutor** | `orderExecutor.ts` | Diffs desired quotes vs resting orders; cancels stale, places new; gas-capped transactions | +| **HealthCheck** | `healthcheck.ts` | HTTP `/health` endpoint exposing live operational metrics | + +### Tick cycle + +Each iteration: + +1. **Update** oracle price, gas price, order book, inventory +2. **Risk check** — halt if collateral below minimum or daily loss exceeded; throttle if gas budget exceeded +3. **Compute quotes** — N levels per side, spread = max(minSpreadBps, gasFloor) + volatility + inventory skew + gas penalty +4. **Reconcile** — selective requoting: only cancel/place orders that changed; skips requote if price drift is below threshold or cooldown hasn't elapsed; skips non-urgent requotes during gas spikes + +### Quoting strategy + +- **Base spread**: configurable minimum in basis points (`minSpreadBps`) +- **Gas floor**: minimum spread to break even on round-trip gas costs (cancel + place) +- **Volatility component**: `volatilityMultiplier * rollingVolatility * 10000` bps +- **Inventory skew**: shifts both bid and ask toward reducing exposure; controlled by `inventorySkewGamma` and `maxSkewTicks` +- **Gas spike penalty**: widens spread proportionally when gas exceeds median by `gasSpikeThresholdPct` +- **Level sizing**: deeper levels get progressively larger quantities (`baseQuantity * level`) + +### Risk controls + +- **Position limits**: max net position size; blocks the side that would increase exposure +- **Utilization cap**: when `requiredMargin / collateral` exceeds `maxUtilizationPct`, only quotes the reducing side +- **Drawdown halt**: stops quoting and cancels all orders if collateral drops below `minCollateralBalance` +- **Daily loss halt**: includes gas costs in PnL calculation; halts if daily loss exceeds `maxDailyLossUsd` +- **Gas budget throttle**: rolling hourly/daily gas budgets; when exceeded, requote cooldown and threshold increase (3x and 2x) +- **Gas spike deferral**: during gas spikes, requotes are deferred unless price drift exceeds `urgentRequoteThresholdTicks` +- **Gas cap**: `maxFeePerGas` is capped at `gasCapMultiplier * medianGasPrice` + +### Graceful shutdown + +On `SIGINT` / `SIGTERM`, the bot cancels all resting orders before exiting. + +## Configuration + +All configuration is via environment variables. Create a `.env` file in the repo root (loaded via `--env-file`). + +### Required + +| Variable | Description | +|---|---| +| `NETWORK` | Chain identifier: `arbitrum`, `arbitrum-sepolia`, or `hardhat` | +| `ETH_NODE_ADDRESS` | RPC endpoint (HTTP or WebSocket) | +| `PERPS_ADDRESS` | Deployed HashPowerPerpsDEX proxy contract address | +| `MAKER_PRIVATE_KEY` | Hex-encoded private key for the MM wallet | + +### Quoting + +| Variable | Default | Description | +|---|---|---| +| `MAKER_LEVELS_PER_SIDE` | `5` | Number of bid/ask levels to quote | +| `MAKER_BASE_QUANTITY` | `1000000` | Base order size (in quantity decimals) | +| `MAKER_MIN_SPREAD_BPS` | `10` | Minimum spread in basis points | +| `MAKER_VOLATILITY_MULTIPLIER` | `2.0` | Volatility scaling factor | +| `MAKER_INVENTORY_SKEW_GAMMA` | `0.5` | Inventory skew strength (0 = disabled, 1 = max) | +| `MAKER_MAX_SKEW_TICKS` | `20` | Maximum skew offset in tick units | + +### Gas management + +| Variable | Default | Description | +|---|---|---| +| `ETH_PRICE_FEED_ADDRESS` | *(none)* | Chainlink ETH/USD price feed address (enables USD gas cost tracking) | +| `MAKER_GAS_SPIKE_THRESHOLD_PCT` | `200` | Gas price % above median to trigger spike mode | +| `MAKER_GAS_CAP_MULTIPLIER` | `2.0` | Max gas price as multiple of median | +| `MAKER_GAS_PENALTY_BPS` | `5` | Additional spread penalty per 100% gas spike | +| `MAKER_MAX_GAS_BUDGET_HOUR_USD` | `50000000` | Max gas spend per rolling hour (collateral decimals) | +| `MAKER_MAX_GAS_BUDGET_DAY_USD` | `500000000` | Max gas spend per rolling day (collateral decimals) | +| `MAKER_URGENT_REQUOTE_TICKS` | `10` | Price drift in ticks that overrides gas spike deferral | + +### Risk + +| Variable | Default | Description | +|---|---|---| +| `MAKER_MAX_POSITION_SIZE` | `100000000` | Max absolute net position (quantity decimals) | +| `MAKER_MAX_UTILIZATION_PCT` | `80` | Max margin utilization before side restrictions | +| `MAKER_MIN_COLLATERAL` | `100000000` | Minimum collateral balance before halt (collateral decimals) | +| `MAKER_MAX_DAILY_LOSS_USD` | `1000000000` | Max daily loss including gas (collateral decimals) | + +### Timing + +| Variable | Default | Description | +|---|---|---| +| `MAKER_POLL_INTERVAL_MS` | `3000` | Main loop interval | +| `MAKER_REQUOTE_THRESHOLD_TICKS` | `2` | Price drift in ticks before requoting | +| `MAKER_REQUOTE_COOLDOWN_MS` | `1000` | Minimum time between requotes | +| `MAKER_RESYNC_INTERVAL_MS` | `60000` | Full order book resync interval | + +### Operational + +| Variable | Default | Description | +|---|---|---| +| `MAKER_DRY_RUN` | `false` | Log orders without submitting transactions | +| `MAKER_HEALTH_PORT` | `3001` | HTTP health endpoint port | +| `MAKER_LOG_LEVEL` | `info` | Pino log level: `trace`, `debug`, `info`, `warn`, `error`, `silent` | + +## Getting started + +### Prerequisites + +- Node.js >= 22.6.0 +- pnpm >= 10 +- Compiled contracts (ABIs) + +### Install + +```bash +cd market-maker +pnpm install +``` + +### Build ABIs + +The market maker uses ABIs generated from the contracts package. The `pretest` script handles this automatically for tests, but for manual setup: + +```bash +cd contracts +pnpm hardhat compile +cp abi/abi.ts ../market-maker/src/abi.ts +``` + +### Run + +```bash +# Production (reads .env from repo root) +pnpm start + +# Development with pretty-printed logs +pnpm dev + +# Dry run (no transactions, logs what would happen) +pnpm dev:dry +``` + +### Example `.env` + +```env +NETWORK=arbitrum-sepolia +ETH_NODE_ADDRESS=https://sepolia-rollup.arbitrum.io/rpc +PERPS_ADDRESS=0x... +MAKER_PRIVATE_KEY=0x... + +MAKER_LEVELS_PER_SIDE=3 +MAKER_BASE_QUANTITY=1000000 +MAKER_MIN_SPREAD_BPS=30 +MAKER_POLL_INTERVAL_MS=5000 +MAKER_MAX_POSITION_SIZE=50000000 +MAKER_DRY_RUN=false +MAKER_HEALTH_PORT=3001 +``` + +## Health endpoint + +`GET http://localhost:{MAKER_HEALTH_PORT}/health` returns JSON: + +```json +{ + "status": "running", + "haltReason": "none", + "throttled": false, + "throttleReason": "none", + "oraclePrice": "2997635", + "volatility": 0.0012, + "netPosition": "-1000000", + "collateral": "100000000000", + "inventorySkew": -0.01, + "utilizationPct": 5, + "ownOrders": 6, + "bestBid": "2990000", + "bestAsk": "3010000", + "gasGwei": "0.10", + "gasSpiking": false, + "gasSpikePct": "0", + "cumulativeGasCostUsd": "0", + "tickCount": 142, + "lastTickAt": 1709500000000, + "ordersPlaced": 12, + "ordersCancelled": 6, + "reconcileCount": 3, + "uptimeSeconds": 426, + "dryRun": false +} +``` + +| Field | Description | +|---|---| +| `status` | `running` or `halted` | +| `haltReason` | `none`, `drawdown`, or `daily_loss` | +| `throttled` | Whether gas budget throttling is active | +| `oraclePrice` | Current oracle price (contract decimals) | +| `volatility` | Rolling price volatility (0-1 scale) | +| `netPosition` | Signed net position size | +| `collateral` | Collateral balance (collateral token decimals) | +| `inventorySkew` | Position skew ratio (-1 to 1) | +| `utilizationPct` | Margin utilization percentage | +| `ownOrders` | Number of resting orders on-chain | +| `bestBid` / `bestAsk` | Current top-of-book prices | +| `ordersPlaced` / `ordersCancelled` | Cumulative order counts since startup | +| `reconcileCount` | Number of requote cycles executed | + +## Testing + +```bash +# Run all tests (unit + e2e) +pnpm test + +# With coverage report +pnpm test:coverage + +# Watch mode +pnpm test:watch + +# Run only process-level e2e tests +node --test --test-force-exit --test-concurrency=1 'tests/market-maker.process.test.ts' +``` + +Tests use a local Hardhat node started automatically. The test suite includes: + +- **Unit tests**: each component tested in isolation with mocked dependencies +- **Component e2e tests** (`market-maker.e2e.test.ts`): full component stack wired together against a local Hardhat node +- **Process e2e tests** (`market-maker.process.test.ts`): spawns the market maker as a separate OS process, verifies behavior by querying on-chain state and the health API + +## Supported networks + +| Network | Chain ID | Notes | +|---|---|---| +| `arbitrum` | 42161 | Production | +| `arbitrum-sepolia` | 421614 | Testnet | +| `hardhat` | 31337 | Local development | + +Transport is selected automatically: WebSocket URLs (`ws://` / `wss://`) use WebSocket transport, otherwise HTTP. diff --git a/market-maker/configs/futures.yml b/market-maker/configs/futures.yml new file mode 100644 index 0000000..779965f --- /dev/null +++ b/market-maker/configs/futures.yml @@ -0,0 +1,80 @@ +# Titan Market Maker - Futures configuration. +# +# Required env vars (loaded via ${VAR}): +# MAKER_PRIVATE_KEY - hex private key of the market-making wallet +# ETH_NODE_ADDRESS - JSON-RPC or websocket endpoint +# FUTURES_ADDRESS - Futures contract address on the configured network +# NETWORK - one of: arbitrum, arbitrum-sepolia, base, base-sepolia, hardhat +# +# Optional env vars (with defaults expressed below): +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator (omit to disable USD gas accounting) +# COMMIT_HASH - injected by CI for /health diagnostics +# NODE_ENV - "development" or "production" +# MAKER_LOG_LEVEL - pino level + +nodeEnv: ${NODE_ENV:-development} +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-info} +dryRun: false + +wallets: + primary: + privateKey: ${MAKER_PRIVATE_KEY} + +network: + name: ${NETWORK} + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + # Seed BookTracker/InventoryManager from this block on startup (events scan). + # Default: use the contract's "list" view functions instead when available. + # eventsFromBlock: 0 + +pricing: + strategy: reservation-price + # Avellaneda–Stoikov risk aversion γ. + # With oracle.volatility in units of "per poll" (~3 s) and marginCallTimeSeconds + # in seconds, tune riskAversion so that at max inventory the mid shifts by ~1 tick. + riskAversion: 0.001 + # Fallback remaining time (seconds) when InstrumentContext.deliveryDate is unavailable. + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + # No inventorySkewGamma: reservation-price absorbs inventory through the formula. + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + # Total per-side budget in USDC base units (6 decimals). + # 500 USDC per side = 500_000_000 + baseQuantity: "500000000" + numLevelsPerSide: 4 + # alpha: 0.6 → each level is 60% of the previous + taperRatio: 0.6 + +risk: + maxPositionSize: "100000000" + maxUtilizationPct: 80 + minCollateralBalance: "100000000" + maxDailyLossUsd: "1000000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + +health: + port: 3001 diff --git a/market-maker/configs/perps.yml b/market-maker/configs/perps.yml new file mode 100644 index 0000000..a38ac46 --- /dev/null +++ b/market-maker/configs/perps.yml @@ -0,0 +1,67 @@ +# Titan Market Maker - Perps configuration. +# +# Required env vars (loaded via ${VAR}): +# MAKER_PRIVATE_KEY - hex private key of the market-making wallet +# ETH_NODE_ADDRESS - JSON-RPC or websocket endpoint +# PERPS_ADDRESS - HashPowerPerpsDEX address on the configured network +# NETWORK - one of: arbitrum, arbitrum-sepolia, base, base-sepolia, hardhat +# +# Optional env vars (with defaults expressed below): +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator (omit to disable USD gas accounting) +# COMMIT_HASH - injected by CI for /health diagnostics +# NODE_ENV - "development" or "production" +# MAKER_LOG_LEVEL - pino level + +nodeEnv: ${NODE_ENV:-development} +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-info} +dryRun: false + +wallets: + primary: + privateKey: ${MAKER_PRIVATE_KEY} + +network: + name: ${NETWORK} + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + minSpreadBps: 10 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + +sizing: + strategy: linear + baseQuantity: "1000000" + numLevelsPerSide: 5 + +risk: + maxPositionSize: "100000000" + maxUtilizationPct: 80 + minCollateralBalance: "100000000" + maxDailyLossUsd: "1000000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + +health: + port: 3001 diff --git a/market-maker/package.json b/market-maker/package.json new file mode 100644 index 0000000..174d559 --- /dev/null +++ b/market-maker/package.json @@ -0,0 +1,33 @@ +{ + "name": "titan-market-maker", + "version": "1.0.0", + "type": "module", + "private": true, + "engines": { + "node": ">=22.6.0" + }, + "scripts": { + "test": "node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "typecheck": "tsc --noEmit", + "start": "MAKER_CONFIG=configs/perps.yml node --env-file=../.env src/main.ts", + "dev": "MAKER_CONFIG=configs/perps.yml node --env-file=../.env src/main.ts | pino-pretty", + "dev:dry": "MAKER_CONFIG=configs/perps.yml MAKER_DRY_RUN=true node --env-file=../.env src/main.ts | pino-pretty", + "pretest": "cd ../contracts && pnpm hardhat compile && cp ./abi/abi.ts ../market-maker/src/adapters/perps/abi.ts" + }, + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "fraction.js": "^5.2.2", + "js-yaml": "^4.1.0", + "pino": "^10.3.1", + "viem": "^2.45.3" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.49", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "pino-pretty": "^13.1.3", + "typescript": "^5.8.0" + }, + "packageManager": "pnpm@10.28.1" +} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml new file mode 100644 index 0000000..5963d78 --- /dev/null +++ b/market-maker/pnpm-lock.yaml @@ -0,0 +1,510 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ajv: + specifier: ^8.17.1 + version: 8.18.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.18.0) + fraction.js: + specifier: ^5.2.2 + version: 5.3.4 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + pino: + specifier: ^10.3.1 + version: 10.3.1 + viem: + specifier: ^2.45.3 + version: 2.48.4(typescript@5.9.3) + devDependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + apps/maker: + dependencies: + '@titan-mm/core': + specifier: workspace:* + version: link:../../packages/core + pino: + specifier: ^10.3.1 + version: 10.3.1 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + packages/core: + dependencies: + ajv: + specifier: ^8.17.1 + version: 8.18.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.18.0) + fraction.js: + specifier: ^5.2.2 + version: 5.3.4 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + pino: + specifier: ^10.3.1 + version: 10.3.1 + viem: + specifier: ^2.45.3 + version: 2.48.4(typescript@5.9.3) + devDependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + viem@2.48.4: + resolution: {integrity: sha512-mReP/rgY2P+WeeRSG4sUvccCLKfyAW1C73Y3KkobAqgzYmVna9qyUMNE44xIUkDtfvRuC33r24UhF4baBYovsg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@pinojs/redact@0.4.0': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@sinclair/typebox@0.34.49': {} + + '@types/js-yaml@4.0.9': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + abitype@1.2.3(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + + atomic-sleep@1.0.0: {} + + colorette@2.0.20: {} + + dateformat@4.6.3: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + eventemitter3@5.0.1: {} + + fast-copy@4.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fraction.js@5.3.4: {} + + help-me@5.0.0: {} + + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + + joycon@3.1.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + minimist@1.2.8: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + ox@0.14.20(typescript@5.9.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + process-warning@5.0.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + require-from-string@2.0.2: {} + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + strip-json-comments@5.0.3: {} + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + viem@2.48.4(typescript@5.9.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + isows: 1.0.7(ws@8.18.3) + ox: 0.14.20(typescript@5.9.3) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + wrappy@1.0.2: {} + + ws@8.18.3: {} diff --git a/market-maker/src/adapter.ts b/market-maker/src/adapter.ts new file mode 100644 index 0000000..b31deee --- /dev/null +++ b/market-maker/src/adapter.ts @@ -0,0 +1,144 @@ +import type { Chain, PublicClient, Transport } from "viem"; +import type { WalletContext } from "./wallet.ts"; + +export type VenueKind = "perps" | "futures" | "options"; + +/** An order resting on the venue owned by the MM. */ +export interface OwnOrder { + orderId: `0x${string}`; + price: bigint; + /** Signed: positive = buy/long, negative = sell/short. */ + quantity: bigint; + /** Optional instrument identifier (for multi-instrument venues like options). */ + instrumentId?: string; +} + +/** A desired quote level produced by a pricing strategy. */ +export interface QuoteLevel { + price: bigint; + /** Signed: positive = buy/long, negative = sell/short. */ + quantity: bigint; +} + +export interface DesiredQuotes { + bids: QuoteLevel[]; + asks: QuoteLevel[]; +} + +/** Position snapshot for a single instrument. */ +export interface Position { + netQuantity: bigint; + entryPrice: bigint; +} + +/** A single resting price level (one side) of an order book. */ +export interface DepthLevel { + price: bigint; + /** Always positive (aggregate quantity at this price). */ + quantity: bigint; +} + +/** Snapshot of one instrument's order book. */ +export interface OrderBookSnapshot { + bids: DepthLevel[]; + asks: DepthLevel[]; +} + +/** Collateral snapshot shared by the venue's entire account (may span multiple instruments). */ +export interface CollateralSnapshot { + /** Collateral deposited into the venue. */ + balance: bigint; + /** Maintenance margin required across all open positions. */ + maintenanceMargin: bigint; + /** Non-deposited wallet balance of the collateral token (can be deposited). */ + walletTokenBalance: bigint; + /** Native gas token balance (for paying gas). */ + nativeBalance: bigint; + /** Address of the collateral ERC-20 token. */ + collateralTokenAddress: `0x${string}`; +} + +/** Venue-specific hint data that pricing strategies can consume. */ +export interface InstrumentContext { + /** Unix seconds of delivery / expiry (optional). */ + deliveryDate?: number; + /** Contract multiplier (e.g. futures days-to-delivery). */ + contractMultiplier?: bigint; + /** Strike price (options). */ + strike?: bigint; + /** Call vs put (options). */ + isCall?: boolean; + /** Underlying spot (options). */ + underlyingSpot?: bigint; +} + +export type VenueEvent = + | { type: "order-created"; order: OwnOrder; isOwn: boolean } + | { type: "order-cancelled"; orderId: `0x${string}`; isOwn: boolean; instrumentId?: string } + | { type: "order-updated"; orderId: `0x${string}`; newQuantity: bigint; isOwn: boolean; instrumentId?: string } + | { type: "order-matched"; makerOrderId: `0x${string}`; isOwn: boolean; instrumentId?: string } + | { type: "position-changed"; instrumentId?: string } + | { type: "depth-changed"; price: bigint; isBid: boolean; newQuantity: bigint; instrumentId?: string }; + +export type Unsubscribe = () => void; + +/** + * Per-instrument interface. Perps/futures return a singleton; options returns one + * per strike/expiry. + */ +export interface InstrumentAdapter { + readonly id: string; + readonly venue: VenueAdapter; + + getIndexPrice(): Promise; + getMinTick(): Promise; + getOwnOrders(): Promise; + getPosition(): Promise; + getContext(): Promise; + /** Snapshot of the resting order book (for full resync). `depth` caps levels per side. */ + getOrderBookSnapshot(opts?: { depth?: number }): Promise; + + buildCancelCalldata(orderId: `0x${string}`): `0x${string}`; + buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}`; + + /** + * Estimate gas for a representative createOrder, used by GasTracker.calibrate. + * Returns 0n on failure. + */ + estimateCreateGas(account: `0x${string}`): Promise; +} + +/** + * Per-venue interface. One per deployed process (a process today has exactly one venue, + * but that may grow). Owns the wallet, collateral, multicall and events. + */ +export interface VenueAdapter { + readonly kind: VenueKind; + readonly wallet: WalletContext; + readonly publicClient: PublicClient; + readonly chain: Chain; + readonly transport: Transport; + + /** Contract address used for tx target and events subscription. */ + readonly address: `0x${string}`; + + listInstruments(): Promise; + getCollateral(): Promise; + topUpCollateral(amount: bigint): Promise; + + /** Batch cancels/creates across one or more instruments. Returns tx hash. */ + multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`>; + + /** + * Subscribe to venue-level events affecting the MM's own orders/positions. + * Implementations should filter to the MM's wallet address internally. + */ + subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe; + + /** + * Optional: initial bootstrap read from contract history (past events). Used to + * seed BookTracker/InventoryManager before live events start flowing. + * Implementations that don't need this can no-op. + */ + bootstrapFromHistory?(opts: { fromBlock?: bigint }): Promise; +} diff --git a/market-maker/src/adapters/futures/abi.ts b/market-maker/src/adapters/futures/abi.ts new file mode 100644 index 0000000..9c7ea76 --- /dev/null +++ b/market-maker/src/adapters/futures/abi.ts @@ -0,0 +1,221 @@ +export const futuresAbi = [ + { + inputs: [], + name: "DeliveryDateExpired", + type: "error", + }, + { + inputs: [], + name: "DeliveryDateNotAvailable", + type: "error", + }, + { + inputs: [], + name: "DeliveryDateShouldBeInTheFuture", + type: "error", + }, + { + inputs: [], + name: "InsufficientMarginBalance", + type: "error", + }, + { + inputs: [], + name: "InvalidPrice", + type: "error", + }, + { + inputs: [], + name: "InvalidQty", + type: "error", + }, + { + inputs: [], + name: "MaxOrdersPerParticipantReached", + type: "error", + }, + { + inputs: [], + name: "OrderNotBelongToSender", + type: "error", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "orderId", type: "bytes32" }, + { indexed: true, internalType: "address", name: "participant", type: "address" }, + ], + name: "OrderClosed", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "orderId", type: "bytes32" }, + { indexed: true, internalType: "address", name: "participant", type: "address" }, + { indexed: false, internalType: "string", name: "destURL", type: "string" }, + { indexed: false, internalType: "uint256", name: "pricePerDay", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "deliveryAt", type: "uint256" }, + { indexed: false, internalType: "bool", name: "isBuy", type: "bool" }, + ], + name: "OrderCreated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, + ], + name: "PositionClosed", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, + { indexed: true, internalType: "address", name: "seller", type: "address" }, + { indexed: true, internalType: "address", name: "buyer", type: "address" }, + { indexed: false, internalType: "uint256", name: "sellPricePerDay", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "buyPricePerDay", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "deliveryAt", type: "uint256" }, + { indexed: false, internalType: "string", name: "destURL", type: "string" }, + { indexed: false, internalType: "bytes32", name: "orderId", type: "bytes32" }, + ], + name: "PositionCreated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, + { indexed: true, internalType: "address", name: "closedBy", type: "address" }, + ], + name: "PositionDeliveryClosed", + type: "event", + }, + { + inputs: [ + { internalType: "uint256", name: "_amount", type: "uint256" }, + ], + name: "addMargin", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "account", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "bytes32", name: "_orderId", type: "bytes32" }, + ], + name: "closeOrder", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_price", type: "uint256" }, + { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, + { internalType: "string", name: "_destURL", type: "string" }, + { internalType: "int8", name: "_qty", type: "int8" }, + ], + name: "createOrder", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "deliveryDurationDays", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getDeliveryDates", + 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: "address", name: "_participant", type: "address" }, + ], + name: "getMinMargin", + outputs: [{ internalType: "int256", name: "", type: "int256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "bytes32", name: "_orderId", type: "bytes32" }, + ], + name: "getOrderById", + outputs: [ + { + components: [ + { internalType: "bool", name: "isBuy", type: "bool" }, + { internalType: "address", name: "participant", type: "address" }, + { internalType: "string", name: "destURL", type: "string" }, + { internalType: "uint256", name: "pricePerDay", type: "uint256" }, + { internalType: "uint256", name: "deliveryAt", type: "uint256" }, + { internalType: "uint256", name: "createdAt", type: "uint256" }, + ], + internalType: "struct Futures.Order", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "_participant", type: "address" }, + { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, + ], + name: "getPositionsByParticipantDeliveryDate", + outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "minimumPriceIncrement", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "bytes[]", name: "data", type: "bytes[]" }, + ], + name: "multicall", + outputs: [{ internalType: "bytes[]", name: "results", type: "bytes[]" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "token", + outputs: [{ internalType: "contract IERC20", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/market-maker/src/adapters/futures/factory.ts b/market-maker/src/adapters/futures/factory.ts new file mode 100644 index 0000000..55893e2 --- /dev/null +++ b/market-maker/src/adapters/futures/factory.ts @@ -0,0 +1,508 @@ +import { encodeFunctionData, erc20Abi } from "viem"; +import type { Log, WatchContractEventReturnType } from "viem"; +import type pino from "pino"; +import { + type AdapterFactoryContext, + type CollateralSnapshot, + type InstrumentAdapter, + type InstrumentContext, + type OrderBookSnapshot, + type OwnOrder, + type Position, + type Unsubscribe, + type VenueAdapter, + type VenueEvent, + type WalletContext, + registerAdapter, +} from "../../index.ts"; +import { futuresAbi } from "./abi.ts"; + +const FUTURES_INSTRUMENT_ID = "futures"; + +interface FuturesVenueOptions { + ctx: AdapterFactoryContext; + address: `0x${string}`; + wallet: WalletContext; + logger: pino.Logger; +} + +/** + * Tracks a matched position so we can reverse it when it's closed. + * Each PositionCreated event represents one unit (qty = ±1). + */ +interface TrackedPosition { + isBuy: boolean; + price: bigint; +} + +type FuturesLog = Log; + +export class FuturesVenueAdapter implements VenueAdapter { + readonly kind = "futures" as const; + readonly wallet: WalletContext; + readonly publicClient: AdapterFactoryContext["network"]["publicClient"]; + readonly chain: AdapterFactoryContext["network"]["chain"]; + readonly transport: AdapterFactoryContext["network"]["transport"]; + readonly address: `0x${string}`; + + private readonly logger: pino.Logger; + private collateralTokenCache: `0x${string}` | null = null; + + /** Open own orders, keyed by orderId. Updated by events + bootstrapFromHistory. */ + readonly ownOrders = new Map<`0x${string}`, OwnOrder>(); + + /** Open positions we are party to, keyed by positionId. */ + private readonly openPositions = new Map<`0x${string}`, TrackedPosition>(); + + /** Net signed quantity across all open positions (+long, -short). */ + netQuantity = 0n; + + /** Average price across open positions (rough P&L reference). */ + entryPrice = 0n; + + constructor(opts: FuturesVenueOptions) { + this.wallet = opts.wallet; + this.publicClient = opts.ctx.network.publicClient; + this.chain = opts.ctx.network.chain; + this.transport = opts.ctx.network.transport; + this.address = opts.address; + this.logger = opts.logger.child({ component: "futures-venue" }); + } + + async listInstruments(): Promise { + return [new FuturesInstrumentAdapter(this)]; + } + + async getCollateralTokenAddress(): Promise<`0x${string}`> { + if (this.collateralTokenCache) return this.collateralTokenCache; + this.collateralTokenCache = await this.publicClient.readContract({ + address: this.address, + abi: futuresAbi, + functionName: "token", + }); + return this.collateralTokenCache; + } + + async getCollateral(): Promise { + const collateralTokenAddress = await this.getCollateralTokenAddress(); + const owner = this.wallet.account.address; + + const [balance, walletTokenBalance, minMarginSigned, nativeBalance] = await Promise.all([ + this.publicClient.readContract({ + address: this.address, + abi: futuresAbi, + functionName: "balanceOf", + args: [owner], + }), + this.publicClient.readContract({ + address: collateralTokenAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }), + this.publicClient.readContract({ + address: this.address, + abi: futuresAbi, + functionName: "getMinMargin", + args: [owner], + }), + this.publicClient.getBalance({ address: owner }), + ]); + + return { + balance, + walletTokenBalance, + maintenanceMargin: minMarginSigned < 0n ? 0n : minMarginSigned, + nativeBalance, + collateralTokenAddress, + }; + } + + async topUpCollateral(amount: bigint): Promise { + if (amount <= 0n) return; + const collateralTokenAddress = await this.getCollateralTokenAddress(); + + this.logger.info({ amount: amount.toString() }, "approving collateral token"); + const approveHash = await this.wallet.walletClient.writeContract({ + address: collateralTokenAddress, + abi: erc20Abi, + functionName: "approve", + args: [this.address, amount], + account: this.wallet.account, + chain: this.chain, + }); + await this.publicClient.waitForTransactionReceipt({ hash: approveHash }); + + this.logger.info({ amount: amount.toString() }, "adding margin to futures contract"); + const marginHash = await this.wallet.walletClient.writeContract({ + address: this.address, + abi: futuresAbi, + functionName: "addMargin", + args: [amount], + account: this.wallet.account, + chain: this.chain, + }); + await this.publicClient.waitForTransactionReceipt({ hash: marginHash }); + } + + async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { + 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, + }); + } + + subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe { + const ownAddress = this.wallet.account.address.toLowerCase(); + const unwatch: WatchContractEventReturnType = this.publicClient.watchContractEvent({ + address: this.address, + abi: futuresAbi, + onLogs: (logs) => { + for (const log of logs) { + const evt = this._decodeAndApply(log as FuturesLog, ownAddress); + if (evt) handler(evt); + } + }, + }); + return () => unwatch(); + } + + /** + * Seed in-memory order/position state from on-chain event history. + * Called once at startup, before live event subscription begins. + * If fromBlock is omitted, scans the last 1000 blocks. + */ + async bootstrapFromHistory(opts: { fromBlock?: bigint } = {}): Promise { + const owner = this.wallet.account.address; + const fromBlock = opts.fromBlock ?? (await this._defaultFromBlock()); + + this.logger.info({ fromBlock: fromBlock.toString() }, "bootstrapping futures state from history"); + + // Seed own orders from OrderCreated / OrderClosed events. + const [createdLogs, closedLogs] = await Promise.all([ + this.publicClient.getContractEvents({ + address: this.address, + abi: futuresAbi, + eventName: "OrderCreated", + args: { participant: owner }, + fromBlock, + toBlock: "latest", + }), + this.publicClient.getContractEvents({ + address: this.address, + abi: futuresAbi, + eventName: "OrderClosed", + args: { participant: owner }, + fromBlock, + toBlock: "latest", + }), + ]); + + for (const log of createdLogs) { + const { orderId, pricePerDay, isBuy } = log.args; + if (!orderId || pricePerDay === undefined || isBuy === undefined) continue; + this.ownOrders.set(orderId, { + orderId, + price: pricePerDay, + quantity: isBuy ? 1n : -1n, + instrumentId: FUTURES_INSTRUMENT_ID, + }); + } + for (const log of closedLogs) { + const { orderId } = log.args; + if (orderId) this.ownOrders.delete(orderId); + } + + // Seed positions from PositionCreated events (indexed on buyer and seller separately). + const [buyerPositionLogs, sellerPositionLogs] = await Promise.all([ + this.publicClient.getContractEvents({ + address: this.address, + abi: futuresAbi, + eventName: "PositionCreated", + args: { buyer: owner }, + fromBlock, + toBlock: "latest", + }), + this.publicClient.getContractEvents({ + address: this.address, + abi: futuresAbi, + eventName: "PositionCreated", + args: { seller: owner }, + fromBlock, + toBlock: "latest", + }), + ]); + + for (const log of buyerPositionLogs) { + const { positionId, buyPricePerDay } = log.args; + if (!positionId || buyPricePerDay === undefined) continue; + this.openPositions.set(positionId, { isBuy: true, price: buyPricePerDay }); + this.netQuantity += 1n; + } + for (const log of sellerPositionLogs) { + const { positionId, sellPricePerDay } = log.args; + if (!positionId || sellPricePerDay === undefined) continue; + this.openPositions.set(positionId, { isBuy: false, price: sellPricePerDay }); + this.netQuantity -= 1n; + } + + // Walk PositionClosed events and reverse any positions we were part of. + if (this.openPositions.size > 0) { + const myPositionIds = new Set(this.openPositions.keys()); + const closedPositionLogs = await this.publicClient.getContractEvents({ + address: this.address, + abi: futuresAbi, + eventName: "PositionClosed", + fromBlock, + toBlock: "latest", + }); + for (const log of closedPositionLogs) { + const { positionId } = log.args; + if (!positionId || !myPositionIds.has(positionId)) continue; + const pos = this.openPositions.get(positionId); + if (!pos) continue; + this.netQuantity += pos.isBuy ? -1n : 1n; + this.openPositions.delete(positionId); + } + } + + this._recalcEntryPrice(); + + this.logger.info( + { + orders: this.ownOrders.size, + netQuantity: this.netQuantity.toString(), + openPositions: this.openPositions.size, + }, + "bootstrap complete", + ); + } + + /** Decode a contract event log, update in-memory state, and return a VenueEvent. */ + _decodeAndApply(log: FuturesLog, ownAddressLower: string): VenueEvent | null { + switch (log.eventName) { + case "OrderCreated": { + const { orderId, participant, pricePerDay, isBuy } = log.args; + if (!orderId || !participant || pricePerDay === undefined || isBuy === undefined) return null; + const isOwn = participant.toLowerCase() === ownAddressLower; + const order: OwnOrder = { + orderId, + price: pricePerDay, + quantity: isBuy ? 1n : -1n, + instrumentId: FUTURES_INSTRUMENT_ID, + }; + if (isOwn) this.ownOrders.set(orderId, order); + return { type: "order-created", order, isOwn }; + } + + case "OrderClosed": { + const { orderId, participant } = log.args; + if (!orderId || !participant) return null; + const isOwn = participant.toLowerCase() === ownAddressLower; + if (isOwn) this.ownOrders.delete(orderId); + return { type: "order-cancelled", orderId, isOwn, instrumentId: FUTURES_INSTRUMENT_ID }; + } + + case "PositionCreated": { + const { positionId, seller, buyer, buyPricePerDay, sellPricePerDay } = log.args; + if (!positionId || !seller || !buyer || buyPricePerDay === undefined || sellPricePerDay === undefined) { + return null; + } + const isBuyer = buyer.toLowerCase() === ownAddressLower; + const isSeller = seller.toLowerCase() === ownAddressLower; + if (isBuyer) { + this.openPositions.set(positionId, { isBuy: true, price: buyPricePerDay }); + this.netQuantity += 1n; + this._recalcEntryPrice(); + } else if (isSeller) { + this.openPositions.set(positionId, { isBuy: false, price: sellPricePerDay }); + this.netQuantity -= 1n; + this._recalcEntryPrice(); + } + return isBuyer || isSeller + ? { type: "position-changed", instrumentId: FUTURES_INSTRUMENT_ID } + : null; + } + + case "PositionClosed": { + const { positionId } = log.args; + if (!positionId) return null; + const pos = this.openPositions.get(positionId); + if (!pos) return null; + this.netQuantity += pos.isBuy ? -1n : 1n; + this.openPositions.delete(positionId); + this._recalcEntryPrice(); + return { type: "position-changed", instrumentId: FUTURES_INSTRUMENT_ID }; + } + + default: + return null; + } + } + + private async _defaultFromBlock(): Promise { + const latest = await this.publicClient.getBlockNumber(); + return latest > 1000n ? latest - 1000n : 0n; + } + + private _recalcEntryPrice(): void { + if (this.openPositions.size === 0) { + this.entryPrice = 0n; + return; + } + let sum = 0n; + for (const { price } of this.openPositions.values()) { + sum += price; + } + this.entryPrice = sum / BigInt(this.openPositions.size); + } +} + +class FuturesInstrumentAdapter implements InstrumentAdapter { + readonly id = FUTURES_INSTRUMENT_ID; + readonly venue: FuturesVenueAdapter; + + /** + * Nearest delivery date, cached from the last getContext() call. + * Must be populated before buildCreateCalldata() is called. + * getContext() is invoked by Quoter.initialize() before any order building. + */ + private cachedDeliveryDate: bigint | null = null; + + constructor(venue: FuturesVenueAdapter) { + this.venue = venue; + } + + async getIndexPrice(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: futuresAbi, + functionName: "getMarketPrice", + }); + } + + async getMinTick(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: futuresAbi, + functionName: "minimumPriceIncrement", + }); + } + + async getOwnOrders(): Promise { + return Array.from(this.venue.ownOrders.values()); + } + + async getPosition(): Promise { + return { + netQuantity: this.venue.netQuantity, + entryPrice: this.venue.entryPrice, + }; + } + + async getContext(): Promise { + const [deliveryDates, durationDays] = await Promise.all([ + this.venue.publicClient.readContract({ + address: this.venue.address, + abi: futuresAbi, + functionName: "getDeliveryDates", + }), + this.venue.publicClient.readContract({ + address: this.venue.address, + abi: futuresAbi, + functionName: "deliveryDurationDays", + }), + ]); + + if (deliveryDates.length === 0) { + throw new Error("futures contract returned no delivery dates"); + } + + this.cachedDeliveryDate = deliveryDates[0]; + + return { + deliveryDate: Number(deliveryDates[0]), + contractMultiplier: BigInt(durationDays), + }; + } + + async getOrderBookSnapshot(_opts?: { depth?: number }): Promise { + // The futures contract exposes no on-chain order book depth query. + // The BookTracker will rely on live events for best bid/ask tracking. + return { bids: [], asks: [] }; + } + + buildCancelCalldata(orderId: `0x${string}`): `0x${string}` { + return encodeFunctionData({ + abi: futuresAbi, + functionName: "closeOrder", + args: [orderId], + }); + } + + /** + * Encode a createOrder calldata. + * Uses the nearest delivery date cached from the last getContext() call. + * Throws if getContext() has not been called yet (enforced by Quoter.initialize()). + * quantity is a signed integer (positive = buy, negative = sell). + */ + buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}` { + if (this.cachedDeliveryDate === null) { + throw new Error( + "FuturesInstrumentAdapter.getContext() must be called before buildCreateCalldata()", + ); + } + const qty = Number(quantity); + if (qty < -128 || qty > 127) { + throw new Error(`Futures quantity ${qty} out of int8 range`); + } + return encodeFunctionData({ + abi: futuresAbi, + functionName: "createOrder", + args: [price, this.cachedDeliveryDate, "", qty as number & { readonly __int8__: true }], + }); + } + + async estimateCreateGas(account: `0x${string}`): Promise { + if (this.cachedDeliveryDate === null) return 0n; + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: futuresAbi, + functionName: "createOrder", + args: [1_000_000n, this.cachedDeliveryDate, "", 1], + account, + }); + } catch { + return 0n; + } + } +} + +/** Register the futures adapter factory. Idempotent. */ +let registered = false; +export function registerFuturesAdapter(): void { + if (registered) return; + registered = true; + registerAdapter("futures", async (ctx) => { + const wallet = ctx.wallets.get(ctx.config.venue.wallet); + const adapter = new FuturesVenueAdapter({ + ctx, + address: ctx.config.venue.address, + wallet, + logger: ctx.logger, + }); + await adapter.bootstrapFromHistory({ + fromBlock: + ctx.config.venue.eventsFromBlock !== undefined + ? BigInt(ctx.config.venue.eventsFromBlock) + : undefined, + }); + return adapter; + }); +} diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts new file mode 100644 index 0000000..0bfde4c --- /dev/null +++ b/market-maker/src/adapters/futures/index.ts @@ -0,0 +1,5 @@ +export * from "./factory.ts"; +export * from "./abi.ts"; + +import { registerFuturesAdapter } from "./factory.ts"; +registerFuturesAdapter(); diff --git a/market-maker/src/adapters/options-stub/index.ts b/market-maker/src/adapters/options-stub/index.ts new file mode 100644 index 0000000..0694023 --- /dev/null +++ b/market-maker/src/adapters/options-stub/index.ts @@ -0,0 +1,77 @@ +/** + * Options venue adapter — architecture placeholder only. + * + * An options venue differs from perps/futures in two ways: + * + * 1. Multi-instrument: VenueAdapter.listInstruments() returns one InstrumentAdapter + * per strike/expiry pair (e.g. "options:BTC-25DEC-50000-C"). The Quoter and + * Executor are already instrument-scoped, so a PortfolioRunner calling them + * per-instrument is all that's needed. + * + * 2. IV-based pricing: InstrumentContext would expose { strike, expiry, isCall, + * underlyingSpot, impliedVol } and a BlackScholesQuoter pricing strategy would + * slot in alongside EffectiveSpreadQuoter / ReservationPriceQuoter. + * + * This stub registers "options" in the adapter registry so that a config file with + * `venue.kind: options` produces a clear "not yet implemented" error rather than + * an opaque "unknown kind" error. + */ + +import type { + CollateralSnapshot, + InstrumentAdapter, + Unsubscribe, + VenueAdapter, + VenueEvent, +} from "../../adapter.ts"; +import { NotImplementedError } from "../../errors.ts"; +import { registerAdapter } from "../../registry.ts"; + +export class OptionsVenueAdapter implements VenueAdapter { + readonly kind = "options" as const; + + get wallet(): never { + throw new NotImplementedError("OptionsVenueAdapter.wallet"); + } + get publicClient(): never { + throw new NotImplementedError("OptionsVenueAdapter.publicClient"); + } + get chain(): never { + throw new NotImplementedError("OptionsVenueAdapter.chain"); + } + get transport(): never { + throw new NotImplementedError("OptionsVenueAdapter.transport"); + } + get address(): never { + throw new NotImplementedError("OptionsVenueAdapter.address"); + } + + listInstruments(): Promise { + throw new NotImplementedError("OptionsVenueAdapter.listInstruments"); + } + getCollateral(): Promise { + throw new NotImplementedError("OptionsVenueAdapter.getCollateral"); + } + topUpCollateral(_amount: bigint): Promise { + throw new NotImplementedError("OptionsVenueAdapter.topUpCollateral"); + } + multicall(_calls: `0x${string}`[], _opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`> { + throw new NotImplementedError("OptionsVenueAdapter.multicall"); + } + subscribeVenueEvents(_handler: (event: VenueEvent) => void): Unsubscribe { + throw new NotImplementedError("OptionsVenueAdapter.subscribeVenueEvents"); + } +} + +let registered = false; +export function registerOptionsAdapter(): void { + if (registered) return; + registered = true; + registerAdapter("options", async () => { + throw new NotImplementedError( + 'Options adapter is not yet implemented. Set venue.kind to "perps" or "futures".', + ); + }); +} + +registerOptionsAdapter(); diff --git a/market-maker/src/adapters/perps/abi.ts b/market-maker/src/adapters/perps/abi.ts new file mode 100644 index 0000000..88f4f1e --- /dev/null +++ b/market-maker/src/adapters/perps/abi.ts @@ -0,0 +1,2207 @@ +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// AggregatorV3Interface +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const aggregatorV3InterfaceAbi = [ + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'description', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_roundId', internalType: 'uint80', type: 'uint80' }], + name: 'getRoundData', + outputs: [ + { name: 'roundId', internalType: 'uint80', type: 'uint80' }, + { name: 'answer', internalType: 'int256', type: 'int256' }, + { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'latestRoundData', + outputs: [ + { name: 'roundId', internalType: 'uint80', type: 'uint80' }, + { name: 'answer', internalType: 'int256', type: 'int256' }, + { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'version', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// HashPowerPerpsDEX +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const hashPowerPerpsDexAbi = [ + { + type: 'constructor', + inputs: [ + { + name: '_minimumPriceIncrement', + internalType: 'uint256', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'error', + inputs: [{ name: 'target', internalType: 'address', type: 'address' }], + name: 'AddressEmptyCode', + }, + { + type: 'error', + inputs: [ + { name: 'implementation', internalType: 'address', type: 'address' }, + ], + name: 'ERC1967InvalidImplementation', + }, + { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, + { + type: 'error', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'allowance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientAllowance', + }, + { + type: 'error', + inputs: [ + { name: 'sender', internalType: 'address', type: 'address' }, + { name: 'balance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientBalance', + }, + { + type: 'error', + inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidApprover', + }, + { + type: 'error', + inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidReceiver', + }, + { + type: 'error', + inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSender', + }, + { + type: 'error', + inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSpender', + }, + { type: 'error', inputs: [], name: 'FailedCall' }, + { type: 'error', inputs: [], name: 'InsufficientCollateral' }, + { type: 'error', inputs: [], name: 'InsufficientMargin' }, + { type: 'error', inputs: [], name: 'InsufficientReservePool' }, + { type: 'error', inputs: [], name: 'InvalidFundingParameters' }, + { type: 'error', inputs: [], name: 'InvalidInitialization' }, + { type: 'error', inputs: [], name: 'InvalidMarginPercent' }, + { type: 'error', inputs: [], name: 'InvalidOracle' }, + { type: 'error', inputs: [], name: 'InvalidPrice' }, + { type: 'error', inputs: [], name: 'InvalidSize' }, + { type: 'error', inputs: [], name: 'MaxOrdersPerParticipantReached' }, + { type: 'error', inputs: [], name: 'MaxPriceLevelsReached' }, + { type: 'error', inputs: [], name: 'NotInitializing' }, + { type: 'error', inputs: [], name: 'NotLiquidatable' }, + { type: 'error', inputs: [], name: 'OracleStale' }, + { type: 'error', inputs: [], name: 'OrderMarginTooLow' }, + { type: 'error', inputs: [], name: 'OrderNotBelongToSender' }, + { + type: 'error', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'OwnableInvalidOwner', + }, + { + type: 'error', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + }, + { + type: 'error', + inputs: [{ name: 'token', internalType: 'address', type: 'address' }], + name: 'SafeERC20FailedOperation', + }, + { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, + { + type: 'error', + inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], + name: 'UUPSUnsupportedProxiableUUID', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'spender', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Approval', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'user', internalType: 'address', type: 'address', indexed: true }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'BadDebt', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'user', internalType: 'address', type: 'address', indexed: true }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'CollateralAdded', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'user', internalType: 'address', type: 'address', indexed: true }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'CollateralRemoved', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'maxBps', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'period', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'FundingParametersUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'user', internalType: 'address', type: 'address', indexed: true }, + { + name: 'amount', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + ], + name: 'FundingSettled', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'fundingRate', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'cumulativeFundingPerUnit', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'timestamp', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'FundingUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'version', + internalType: 'uint64', + type: 'uint64', + indexed: false, + }, + ], + name: 'Initialized', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newLiquidationFee', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'LiquidationFeeUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newMaintenanceMarginPercent', + internalType: 'uint8', + type: 'uint8', + indexed: false, + }, + ], + name: 'MaintenanceMarginPercentUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newMarginPercent', + internalType: 'uint8', + type: 'uint8', + indexed: false, + }, + ], + name: 'MarginPercentUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newTakerFeeBps', + internalType: 'int16', + type: 'int16', + indexed: false, + }, + { + name: 'newMakerFeeBps', + internalType: 'int16', + type: 'int16', + indexed: false, + }, + ], + name: 'MatchFeeUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newMinimumMarginPerOrder', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'MinimumMarginPerOrderUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'orderId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'participant', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'OrderCancelled', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'orderId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'participant', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'price', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'quantity', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + ], + name: 'OrderCreated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'makerOrderId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'maker', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'taker', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'tradePrice', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'takerQuantity', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'makerFee', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'takerFee', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'makerNetQtyAfter', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'takerNetQtyAfter', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { + name: 'makerEntryPriceAfter', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'takerEntryPriceAfter', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'OrderMatched', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'orderId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'participant', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'newQuantity', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + ], + name: 'OrderUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'previousOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'newOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'user', internalType: 'address', type: 'address', indexed: true }, + { + name: 'liquidator', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'positionSize', + internalType: 'int256', + type: 'int256', + indexed: false, + }, + { name: 'pnl', internalType: 'int256', type: 'int256', indexed: false }, + { + name: 'liquidatorFee', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'PositionLiquidated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Transfer', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'implementation', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'Upgraded', + }, + { + type: 'function', + inputs: [], + name: 'FUNDING_DECIMALS', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'MAX_ORDERS_PER_PARTICIPANT', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'MAX_PRICE_LEVELS_PER_SIDE', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'QUANTITY_DECIMALS', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'UPGRADE_INTERFACE_VERSION', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], + name: 'addCollateral', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_amount', internalType: 'uint256', type: 'uint256' }, + { name: '_deadline', internalType: 'uint256', type: 'uint256' }, + { name: '_v', internalType: 'uint8', type: 'uint8' }, + { name: '_r', internalType: 'bytes32', type: 'bytes32' }, + { name: '_s', internalType: 'bytes32', type: 'bytes32' }, + ], + name: 'addCollateralWithPermit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_orderId', internalType: 'bytes32', type: 'bytes32' }], + name: 'cancelOrder', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'collateralToken', + outputs: [{ name: '', internalType: 'contract IERC20', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: '_price', internalType: 'uint256', type: 'uint256' }, + { name: '_quantity', internalType: 'int256', type: 'int256' }, + ], + name: 'createOrder', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'cumulativeFundingPerUnit', + outputs: [{ name: '', internalType: 'int256', type: 'int256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], + name: 'depositReservePool', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'fundingPeriod', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'fundingRateMaxBps', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getBestAskPrice', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getBestBidPrice', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getInitialMargin', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getMaintenanceMargin', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getMarketPrice', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_orderId', internalType: 'bytes32', type: 'bytes32' }], + name: 'getOrder', + outputs: [ + { + name: '', + internalType: 'struct HashPowerPerpsDEX.Order', + type: 'tuple', + components: [ + { name: 'participant', internalType: 'address', type: 'address' }, + { name: 'price', internalType: 'uint256', type: 'uint256' }, + { name: 'quantity', internalType: 'int256', type: 'int256' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_maxLevels', internalType: 'uint256', type: 'uint256' }], + name: 'getOrderBookPrices', + outputs: [ + { name: 'bidPrices', internalType: 'uint256[]', type: 'uint256[]' }, + { name: 'askPrices', internalType: 'uint256[]', type: 'uint256[]' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getPendingFunding', + outputs: [{ name: '', internalType: 'int256', type: 'int256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: '_price', internalType: 'uint256', type: 'uint256' }, + { name: '_isBid', internalType: 'bool', type: 'bool' }, + ], + name: 'getQuantityAtPrice', + outputs: [ + { name: 'totalQuantity', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getRequiredMargin', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getUnrealizedPnl', + outputs: [{ name: '', internalType: 'int256', type: 'int256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getUserOrders', + outputs: [{ name: '', internalType: 'bytes32[]', type: 'bytes32[]' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'getUserPosition', + outputs: [ + { + name: '', + internalType: 'struct HashPowerPerpsDEX.Position', + type: 'tuple', + components: [ + { name: 'netQuantity', internalType: 'int256', type: 'int256' }, + { + name: 'aggregatedEntryPrice', + internalType: 'uint256', + type: 'uint256', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getUsersWithPositions', + outputs: [{ name: '', internalType: 'address[]', type: 'address[]' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { + name: '_collateralToken', + internalType: 'contract IERC20Metadata', + type: 'address', + }, + { + name: '_priceOracle', + internalType: 'contract AggregatorV3Interface', + type: 'address', + }, + { name: '_marginPercent', internalType: 'uint8', type: 'uint8' }, + { + name: '_maintenanceMarginPercent', + internalType: 'uint8', + type: 'uint8', + }, + ], + name: 'initialize', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'isLiquidatable', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'lastFundingUpdateTime', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_users', internalType: 'address[]', type: 'address[]' }], + name: 'liquidateBatch', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'liquidationFee', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'maintenanceMarginPercent', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'makerFeeBps', + outputs: [{ name: '', internalType: 'int16', type: 'int16' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'marginPercent', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'minimumMarginPerOrder', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'minimumPriceIncrement', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'data', internalType: 'bytes[]', type: 'bytes[]' }], + name: 'multicall', + outputs: [{ name: 'results', internalType: 'bytes[]', type: 'bytes[]' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'name', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'priceOracle', + outputs: [ + { + name: '', + internalType: 'contract AggregatorV3Interface', + type: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'proxiableUUID', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], + name: 'removeCollateral', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'resetState', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_fundingRateMaxBps', internalType: 'uint256', type: 'uint256' }, + { name: '_fundingPeriod', internalType: 'uint256', type: 'uint256' }, + ], + name: 'setFundingParameters', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_liquidationFee', internalType: 'uint256', type: 'uint256' }, + ], + name: 'setLiquidationFee', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { + name: '_maintenanceMarginPercent', + internalType: 'uint8', + type: 'uint8', + }, + ], + name: 'setMaintenanceMarginPercent', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_marginPercent', internalType: 'uint8', type: 'uint8' }], + name: 'setMarginPercent', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_takerFeeBps', internalType: 'int16', type: 'int16' }, + { name: '_makerFeeBps', internalType: 'int16', type: 'int16' }, + ], + name: 'setMatchFee', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { + name: '_minimumMarginPerOrder', + internalType: 'uint256', + type: 'uint256', + }, + ], + name: 'setMinimumMarginPerOrder', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { + name: '_oracle', + internalType: 'contract AggregatorV3Interface', + type: 'address', + }, + ], + name: 'setOracle', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_price', internalType: 'uint256', type: 'uint256' }, + { name: '_quantity', internalType: 'int256', type: 'int256' }, + ], + name: 'simulateOrder', + outputs: [ + { name: 'filledQuantity', internalType: 'int256', type: 'int256' }, + { name: 'averageFillPrice', internalType: 'uint256', type: 'uint256' }, + { name: 'remainingQuantity', internalType: 'int256', type: 'int256' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'symbol', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'takerFeeBps', + outputs: [{ name: '', internalType: 'int16', type: 'int16' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalSupply', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'updateFunding', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'newImplementation', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'upgradeToAndCall', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], + name: 'withdrawReservePool', + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// IERC20 +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const ierc20Abi = [ + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'spender', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Approval', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Transfer', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalSupply', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// IERC20Permit +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const ierc20PermitAbi = [ + { + type: 'function', + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'nonces', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'v', internalType: 'uint8', type: 'uint8' }, + { name: 'r', internalType: 'bytes32', type: 'bytes32' }, + { name: 's', internalType: 'bytes32', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// IERC5267 +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const ierc5267Abi = [ + { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, + { + type: 'function', + inputs: [], + name: 'eip712Domain', + outputs: [ + { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, + { name: 'name', internalType: 'string', type: 'string' }, + { name: 'version', internalType: 'string', type: 'string' }, + { name: 'chainId', internalType: 'uint256', type: 'uint256' }, + { name: 'verifyingContract', internalType: 'address', type: 'address' }, + { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, + { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, + ], + stateMutability: 'view', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Multicall3 +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const multicall3Abi = [ + { + type: 'function', + inputs: [ + { + name: 'calls', + internalType: 'struct Multicall3.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'aggregate', + outputs: [ + { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, + { name: 'returnData', internalType: 'bytes[]', type: 'bytes[]' }, + ], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { + name: 'calls', + internalType: 'struct Multicall3.Call3[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'allowFailure', internalType: 'bool', type: 'bool' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'aggregate3', + outputs: [ + { + name: 'returnData', + internalType: 'struct Multicall3.Result[]', + type: 'tuple[]', + components: [ + { name: 'success', internalType: 'bool', type: 'bool' }, + { name: 'returnData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { + name: 'calls', + internalType: 'struct Multicall3.Call3Value[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'allowFailure', internalType: 'bool', type: 'bool' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'aggregate3Value', + outputs: [ + { + name: 'returnData', + internalType: 'struct Multicall3.Result[]', + type: 'tuple[]', + components: [ + { name: 'success', internalType: 'bool', type: 'bool' }, + { name: 'returnData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { + name: 'calls', + internalType: 'struct Multicall3.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'blockAndAggregate', + outputs: [ + { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, + { name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }, + { + name: 'returnData', + internalType: 'struct Multicall3.Result[]', + type: 'tuple[]', + components: [ + { name: 'success', internalType: 'bool', type: 'bool' }, + { name: 'returnData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [], + name: 'getBasefee', + outputs: [{ name: 'basefee', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'blockNumber', internalType: 'uint256', type: 'uint256' }], + name: 'getBlockHash', + outputs: [{ name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getBlockNumber', + outputs: [ + { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getChainId', + outputs: [{ name: 'chainid', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getCurrentBlockCoinbase', + outputs: [{ name: 'coinbase', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getCurrentBlockDifficulty', + outputs: [{ name: 'difficulty', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getCurrentBlockGasLimit', + outputs: [{ name: 'gaslimit', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getCurrentBlockTimestamp', + outputs: [{ name: 'timestamp', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'addr', internalType: 'address', type: 'address' }], + name: 'getEthBalance', + outputs: [{ name: 'balance', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getLastBlockHash', + outputs: [{ name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'requireSuccess', internalType: 'bool', type: 'bool' }, + { + name: 'calls', + internalType: 'struct Multicall3.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'tryAggregate', + outputs: [ + { + name: 'returnData', + internalType: 'struct Multicall3.Result[]', + type: 'tuple[]', + components: [ + { name: 'success', internalType: 'bool', type: 'bool' }, + { name: 'returnData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { name: 'requireSuccess', internalType: 'bool', type: 'bool' }, + { + name: 'calls', + internalType: 'struct Multicall3.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'callData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'tryBlockAndAggregate', + outputs: [ + { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, + { name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }, + { + name: 'returnData', + internalType: 'struct Multicall3.Result[]', + type: 'tuple[]', + components: [ + { name: 'success', internalType: 'bool', type: 'bool' }, + { name: 'returnData', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + stateMutability: 'payable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// PerpsSimpleDLL +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const perpsSimpleDllAbi = [ + { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { + type: 'error', + inputs: [{ name: 'target', internalType: 'address', type: 'address' }], + name: 'AddressEmptyCode', + }, + { + type: 'error', + inputs: [ + { name: 'implementation', internalType: 'address', type: 'address' }, + ], + name: 'ERC1967InvalidImplementation', + }, + { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, + { + type: 'error', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'allowance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientAllowance', + }, + { + type: 'error', + inputs: [ + { name: 'sender', internalType: 'address', type: 'address' }, + { name: 'balance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientBalance', + }, + { + type: 'error', + inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidApprover', + }, + { + type: 'error', + inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidReceiver', + }, + { + type: 'error', + inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSender', + }, + { + type: 'error', + inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSpender', + }, + { type: 'error', inputs: [], name: 'FailedCall' }, + { type: 'error', inputs: [], name: 'InvalidInitialization' }, + { type: 'error', inputs: [], name: 'NotInitializing' }, + { + type: 'error', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'OwnableInvalidOwner', + }, + { + type: 'error', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + }, + { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, + { + type: 'error', + inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], + name: 'UUPSUnsupportedProxiableUUID', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'spender', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Approval', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'version', + internalType: 'uint64', + type: 'uint64', + indexed: false, + }, + ], + name: 'Initialized', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'previousOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'newOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Transfer', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'implementation', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'Upgraded', + }, + { + type: 'function', + inputs: [], + name: 'UPGRADE_INTERFACE_VERSION', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'name', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'proxiableUUID', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'symbol', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalSupply', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'newImplementation', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'upgradeToAndCall', + outputs: [], + stateMutability: 'payable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// PriceOracleMock +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const priceOracleMockAbi = [ + { + type: 'constructor', + inputs: [ + { name: 'initialPrice', internalType: 'int256', type: 'int256' }, + { name: 'decimals_', internalType: 'uint8', type: 'uint8' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'description', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'freezeTimestamp', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'uint80', type: 'uint80' }], + name: 'getRoundData', + outputs: [ + { name: 'roundId', internalType: 'uint80', type: 'uint80' }, + { name: 'answer', internalType: 'int256', type: 'int256' }, + { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'latestRoundData', + outputs: [ + { name: 'roundId', internalType: 'uint80', type: 'uint80' }, + { name: 'answer', internalType: 'int256', type: 'int256' }, + { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, + { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'price', internalType: 'int256', type: 'int256' }, + { name: 'decimals_', internalType: 'uint8', type: 'uint8' }, + ], + name: 'setPrice', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unfreezeTimestamp', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'version', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'pure', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// USDCMock +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const usdcMockAbi = [ + { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { type: 'error', inputs: [], name: 'ECDSAInvalidSignature' }, + { + type: 'error', + inputs: [{ name: 'length', internalType: 'uint256', type: 'uint256' }], + name: 'ECDSAInvalidSignatureLength', + }, + { + type: 'error', + inputs: [{ name: 's', internalType: 'bytes32', type: 'bytes32' }], + name: 'ECDSAInvalidSignatureS', + }, + { + type: 'error', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'allowance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientAllowance', + }, + { + type: 'error', + inputs: [ + { name: 'sender', internalType: 'address', type: 'address' }, + { name: 'balance', internalType: 'uint256', type: 'uint256' }, + { name: 'needed', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC20InsufficientBalance', + }, + { + type: 'error', + inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidApprover', + }, + { + type: 'error', + inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidReceiver', + }, + { + type: 'error', + inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSender', + }, + { + type: 'error', + inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], + name: 'ERC20InvalidSpender', + }, + { + type: 'error', + inputs: [{ name: 'deadline', internalType: 'uint256', type: 'uint256' }], + name: 'ERC2612ExpiredSignature', + }, + { + type: 'error', + inputs: [ + { name: 'signer', internalType: 'address', type: 'address' }, + { name: 'owner', internalType: 'address', type: 'address' }, + ], + name: 'ERC2612InvalidSigner', + }, + { + type: 'error', + inputs: [ + { name: 'account', internalType: 'address', type: 'address' }, + { name: 'currentNonce', internalType: 'uint256', type: 'uint256' }, + ], + name: 'InvalidAccountNonce', + }, + { type: 'error', inputs: [], name: 'InvalidShortString' }, + { + type: 'error', + inputs: [{ name: 'str', internalType: 'string', type: 'string' }], + name: 'StringTooLong', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'spender', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Approval', + }, + { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'value', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Transfer', + }, + { + type: 'function', + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'pure', + }, + { + type: 'function', + inputs: [], + name: 'eip712Domain', + outputs: [ + { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, + { name: 'name', internalType: 'string', type: 'string' }, + { name: 'version', internalType: 'string', type: 'string' }, + { name: 'chainId', internalType: 'uint256', type: 'uint256' }, + { name: 'verifyingContract', internalType: 'address', type: 'address' }, + { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, + { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'name', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'nonces', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'v', internalType: 'uint8', type: 'uint8' }, + { name: 'r', internalType: 'bytes32', type: 'bytes32' }, + { name: 's', internalType: 'bytes32', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'symbol', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalSupply', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Versionable +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const versionableAbi = [ + { + type: 'function', + inputs: [], + name: 'VERSION', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, +] as const diff --git a/market-maker/src/adapters/perps/collateral.ts b/market-maker/src/adapters/perps/collateral.ts new file mode 100644 index 0000000..b03ba41 --- /dev/null +++ b/market-maker/src/adapters/perps/collateral.ts @@ -0,0 +1,117 @@ +import type { Account, Chain, PublicClient, WalletClient } from "viem"; +import { erc20Abi } from "viem"; +import type pino from "pino"; +import { hashPowerPerpsDexAbi, ierc20PermitAbi, ierc5267Abi } from "./abi.ts"; + +const permitTypes = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +/** + * Deposit `amount` of the perps' collateral token into the venue using ERC-2612 permit + * (no prior approve tx needed). Token must implement EIP-2612; if it also implements + * EIP-5267 we use that to discover the permit domain, otherwise we fall back to + * `name()` + `version()`. + */ +export async function topUpCollateralWithPermit(opts: { + publicClient: PublicClient; + walletClient: WalletClient; + account: Account; + chain: Chain; + perpsAddress: `0x${string}`; + collateralTokenAddress: `0x${string}`; + amount: bigint; + logger: pino.Logger; +}): Promise { + const { + publicClient, + walletClient, + account, + chain, + perpsAddress, + collateralTokenAddress, + amount, + logger, + } = opts; + if (amount <= 0n) return; + const owner = account.address; + logger.info({ amount: amount.toString() }, "topping up collateral"); + + const [nameResult, versionResult, nonceResult, eip712DomainResult] = await publicClient.multicall({ + allowFailure: true, + contracts: [ + { address: collateralTokenAddress, abi: erc20Abi, functionName: "name" }, + { + address: collateralTokenAddress, + abi: [ + { + inputs: [], + name: "version", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + ], + functionName: "version", + }, + { + address: collateralTokenAddress, + abi: ierc20PermitAbi, + functionName: "nonces", + args: [owner], + }, + { + address: collateralTokenAddress, + abi: ierc5267Abi, + functionName: "eip712Domain", + }, + ], + }); + + let domain: { name: string; version: string; chainId: number; verifyingContract: `0x${string}` }; + if (eip712DomainResult.status === "success") { + const [, name, version, chainId, verifyingContract] = eip712DomainResult.result; + domain = { name, version, chainId: Number(chainId), verifyingContract }; + } else { + if (nameResult.status === "failure") throw nameResult.error; + domain = { + name: nameResult.result, + version: versionResult.status === "success" ? versionResult.result || "1" : "1", + chainId: chain.id, + verifyingContract: collateralTokenAddress, + }; + } + + if (nonceResult.status === "failure") throw nonceResult.error; + const nonce = nonceResult.result; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); + + const signature = await walletClient.signTypedData({ + account, + domain, + types: permitTypes, + primaryType: "Permit", + message: { owner, spender: perpsAddress, value: amount, nonce, deadline }, + }); + + const r = `0x${signature.slice(2, 66)}` as `0x${string}`; + const s = `0x${signature.slice(66, 130)}` as `0x${string}`; + const v = Number.parseInt(signature.slice(130, 132), 16); + + const hash = await walletClient.writeContract({ + address: perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "addCollateralWithPermit", + args: [amount, deadline, v, r, s], + account, + chain, + }); + await publicClient.waitForTransactionReceipt({ hash }); + logger.info({ amount: amount.toString() }, "collateral topped up"); +} diff --git a/market-maker/src/adapters/perps/factory.ts b/market-maker/src/adapters/perps/factory.ts new file mode 100644 index 0000000..6b823a2 --- /dev/null +++ b/market-maker/src/adapters/perps/factory.ts @@ -0,0 +1,331 @@ +import { encodeFunctionData, erc20Abi } from "viem"; +import type { Log, WatchContractEventReturnType } from "viem"; +import type pino from "pino"; +import { + type AdapterFactoryContext, + type CollateralSnapshot, + type DepthLevel, + type InstrumentAdapter, + type InstrumentContext, + type OrderBookSnapshot, + type OwnOrder, + type Position, + type Unsubscribe, + type VenueAdapter, + type VenueEvent, + type WalletContext, + registerAdapter, +} from "../../index.ts"; +import { hashPowerPerpsDexAbi, multicall3Abi } from "./abi.ts"; +import { topUpCollateralWithPermit } from "./collateral.ts"; + +const PERPS_INSTRUMENT_ID = "perps"; + +interface PerpsVenueOptions { + ctx: AdapterFactoryContext; + address: `0x${string}`; + wallet: WalletContext; + logger: pino.Logger; +} + +export class PerpsVenueAdapter implements VenueAdapter { + readonly kind = "perps" as const; + readonly wallet: WalletContext; + readonly publicClient: AdapterFactoryContext["network"]["publicClient"]; + readonly chain: AdapterFactoryContext["network"]["chain"]; + readonly transport: AdapterFactoryContext["network"]["transport"]; + readonly address: `0x${string}`; + + private readonly logger: pino.Logger; + private collateralTokenCache: `0x${string}` | null = null; + + constructor(opts: PerpsVenueOptions) { + this.wallet = opts.wallet; + this.publicClient = opts.ctx.network.publicClient; + this.chain = opts.ctx.network.chain; + this.transport = opts.ctx.network.transport; + this.address = opts.address; + this.logger = opts.logger.child({ component: "perps-venue" }); + } + + async listInstruments(): Promise { + return [new PerpsInstrumentAdapter(this)]; + } + + async getCollateralTokenAddress(): Promise<`0x${string}`> { + if (this.collateralTokenCache) return this.collateralTokenCache; + this.collateralTokenCache = await this.publicClient.readContract({ + address: this.address, + abi: hashPowerPerpsDexAbi, + functionName: "collateralToken", + }); + return this.collateralTokenCache; + } + + async getCollateral(): Promise { + const collateralTokenAddress = await this.getCollateralTokenAddress(); + const multicall3Address = this.chain.contracts?.multicall3?.address as `0x${string}` | undefined; + if (!multicall3Address) { + throw new Error(`chain ${this.chain.name} has no multicall3 address configured`); + } + const owner = this.wallet.account.address; + const results = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.address, abi: hashPowerPerpsDexAbi, functionName: "balanceOf", args: [owner] }, + { address: collateralTokenAddress, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: this.address, abi: hashPowerPerpsDexAbi, functionName: "getMaintenanceMargin", args: [owner] }, + { address: multicall3Address, abi: multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ], + }); + return { + balance: results[0], + walletTokenBalance: results[1], + maintenanceMargin: results[2], + nativeBalance: results[3], + collateralTokenAddress, + }; + } + + async topUpCollateral(amount: bigint): Promise { + if (amount <= 0n) return; + const collateralTokenAddress = await this.getCollateralTokenAddress(); + await topUpCollateralWithPermit({ + publicClient: this.publicClient, + walletClient: this.wallet.walletClient, + account: this.wallet.account, + chain: this.chain, + perpsAddress: this.address, + collateralTokenAddress, + amount, + logger: this.logger, + }); + } + + async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { + 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, + }); + } + + subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe { + const ownAddress = this.wallet.account.address.toLowerCase(); + const unwatch: WatchContractEventReturnType = this.publicClient.watchContractEvent({ + address: this.address, + abi: hashPowerPerpsDexAbi, + onLogs: (logs) => { + for (const log of logs) { + const evt = decodeEvent(log as PerpsLog, ownAddress); + if (evt) handler(evt); + } + }, + }); + return () => unwatch(); + } +} + +class PerpsInstrumentAdapter implements InstrumentAdapter { + readonly id = PERPS_INSTRUMENT_ID; + readonly venue: PerpsVenueAdapter; + + constructor(venue: PerpsVenueAdapter) { + this.venue = venue; + } + + async getIndexPrice(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getMarketPrice", + }); + } + + async getMinTick(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "minimumPriceIncrement", + }); + } + + async getOwnOrders(): Promise { + const owner = this.venue.wallet.account.address; + const orderIds = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [owner], + }); + if (orderIds.length === 0) return []; + const calls = orderIds.map((id) => ({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + const results = await this.venue.publicClient.multicall({ allowFailure: false, contracts: calls }); + return orderIds.map((orderId, i) => ({ + orderId, + price: results[i].price, + quantity: results[i].quantity, + instrumentId: this.id, + })); + } + + async getPosition(): Promise { + const owner = this.venue.wallet.account.address; + const pos = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getUserPosition", + args: [owner], + }); + return { netQuantity: pos.netQuantity, entryPrice: pos.aggregatedEntryPrice }; + } + + async getContext(): Promise { + return {}; + } + + async getOrderBookSnapshot(opts: { depth?: number } = {}): Promise { + const depth = BigInt(opts.depth ?? 200); + const [bidPrices, askPrices] = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getOrderBookPrices", + args: [depth], + }); + if (bidPrices.length === 0 && askPrices.length === 0) { + return { bids: [], asks: [] }; + } + const depthCalls = [ + ...bidPrices.map((p) => ({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, true] as const, + })), + ...askPrices.map((p) => ({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, false] as const, + })), + ]; + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: depthCalls, + }); + const bids: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); + const asks: DepthLevel[] = askPrices.map((p, i) => ({ + price: p, + quantity: results[bidPrices.length + i], + })); + return { bids, asks }; + } + + buildCancelCalldata(orderId: `0x${string}`): `0x${string}` { + return encodeFunctionData({ + abi: hashPowerPerpsDexAbi, + functionName: "cancelOrder", + args: [orderId], + }); + } + + buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}` { + return encodeFunctionData({ + abi: hashPowerPerpsDexAbi, + functionName: "createOrder", + args: [price, quantity], + }); + } + + async estimateCreateGas(account: `0x${string}`): Promise { + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: hashPowerPerpsDexAbi, + functionName: "createOrder", + args: [1_000_000n, 1_000_000n], + account, + }); + } catch { + return 0n; + } + } +} + +type PerpsLog = Log; + +function decodeEvent(log: PerpsLog, ownAddressLower: string): VenueEvent | null { + switch (log.eventName) { + case "OrderCreated": { + const { orderId, participant, price, quantity } = log.args; + if (!orderId || !participant || price === undefined || quantity === undefined) return null; + const isOwn = participant.toLowerCase() === ownAddressLower; + return { + type: "order-created", + order: { orderId, price, quantity, instrumentId: PERPS_INSTRUMENT_ID }, + isOwn, + }; + } + case "OrderCancelled": { + const { orderId, participant } = log.args; + if (!orderId || !participant) return null; + return { + type: "order-cancelled", + orderId, + isOwn: participant.toLowerCase() === ownAddressLower, + instrumentId: PERPS_INSTRUMENT_ID, + }; + } + case "OrderUpdated": { + const { orderId, participant, newQuantity } = log.args; + if (!orderId || !participant || newQuantity === undefined) return null; + return { + type: "order-updated", + orderId, + newQuantity, + isOwn: participant.toLowerCase() === ownAddressLower, + instrumentId: PERPS_INSTRUMENT_ID, + }; + } + case "OrderMatched": { + const { makerOrderId, maker, taker } = log.args; + if (!makerOrderId) return null; + const isOwn = + (maker?.toLowerCase() === ownAddressLower) || + (taker?.toLowerCase() === ownAddressLower); + return { + type: "order-matched", + makerOrderId, + isOwn, + instrumentId: PERPS_INSTRUMENT_ID, + }; + } + default: + return null; + } +} + +/** Register the perps factory on import. Idempotent. */ +let registered = false; +export function registerPerpsAdapter(): void { + if (registered) return; + registered = true; + registerAdapter("perps", async (ctx) => { + const wallet = ctx.wallets.get(ctx.config.venue.wallet); + return new PerpsVenueAdapter({ + ctx, + address: ctx.config.venue.address, + wallet, + logger: ctx.logger, + }); + }); +} diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts new file mode 100644 index 0000000..8878b65 --- /dev/null +++ b/market-maker/src/adapters/perps/index.ts @@ -0,0 +1,6 @@ +export * from "./factory.ts"; +export * from "./abi.ts"; +export { topUpCollateralWithPermit } from "./collateral.ts"; + +import { registerPerpsAdapter } from "./factory.ts"; +registerPerpsAdapter(); diff --git a/market-maker/src/bookTracker.ts b/market-maker/src/bookTracker.ts new file mode 100644 index 0000000..4425adc --- /dev/null +++ b/market-maker/src/bookTracker.ts @@ -0,0 +1,135 @@ +import type pino from "pino"; +import type { InstrumentAdapter, OwnOrder, Unsubscribe, VenueEvent } from "./adapter.ts"; + +export interface BookTrackerConfig { + /** Periodic full resync interval (ms). Live events keep state fresh in between. */ + resyncIntervalMs: number; + /** Levels per side requested in the snapshot. */ + snapshotDepth?: number; +} + +/** + * Tracks the resting order book and the MM's own orders for a single instrument. + * + * Sources state from: + * - periodic full snapshot via `instrument.getOrderBookSnapshot()` and `getOwnOrders()` + * - live updates via the venue's `subscribeVenueEvents` (filtered to this instrument) + * + * The adapter is responsible for filtering events by `isOwn` (the MM's wallet). + */ +export class BookTracker { + bestBid = 0n; + bestAsk = 0n; + midPrice = 0n; + + /** orderId -> own order resting on the venue. */ + readonly ownOrders = new Map<`0x${string}`, OwnOrder>(); + + private readonly bidDepth = new Map(); + private readonly askDepth = new Map(); + + private readonly instrument: InstrumentAdapter; + private readonly logger: pino.Logger; + private readonly cfg: BookTrackerConfig; + + private unsubscribe: Unsubscribe | null = null; + private lastResyncAt = 0; + + constructor(instrument: InstrumentAdapter, cfg: BookTrackerConfig, logger: pino.Logger) { + this.instrument = instrument; + this.cfg = cfg; + this.logger = logger.child({ component: "book", instrument: instrument.id }); + } + + async start(): Promise { + await this.fullResync(); + this.subscribe(); + } + + stop(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + } + + /** Periodic resync if interval elapsed. Called each tick. */ + async refresh(): Promise { + if (Date.now() - this.lastResyncAt > this.cfg.resyncIntervalMs) { + await this.fullResync(); + } + } + + depthAtPrice(price: bigint, isBid: boolean): bigint { + return (isBid ? this.bidDepth : this.askDepth).get(price) ?? 0n; + } + + private async fullResync(): Promise { + const [snapshot, ownOrders] = await Promise.all([ + this.instrument.getOrderBookSnapshot({ depth: this.cfg.snapshotDepth ?? 200 }), + this.instrument.getOwnOrders(), + ]); + + this.bidDepth.clear(); + this.askDepth.clear(); + for (const lvl of snapshot.bids) this.bidDepth.set(lvl.price, lvl.quantity); + for (const lvl of snapshot.asks) this.askDepth.set(lvl.price, lvl.quantity); + + this.bestBid = snapshot.bids.length > 0 ? snapshot.bids[0].price : 0n; + this.bestAsk = snapshot.asks.length > 0 ? snapshot.asks[0].price : 0n; + this.midPrice = this.bestBid > 0n && this.bestAsk > 0n ? (this.bestBid + this.bestAsk) / 2n : 0n; + + this.ownOrders.clear(); + for (const order of ownOrders) { + this.ownOrders.set(order.orderId, order); + } + + this.lastResyncAt = Date.now(); + this.logger.info( + { + bestBid: this.bestBid.toString(), + bestAsk: this.bestAsk.toString(), + ownOrders: this.ownOrders.size, + }, + "book resync", + ); + } + + private subscribe(): void { + this.unsubscribe = this.instrument.venue.subscribeVenueEvents((evt) => this.handleEvent(evt)); + } + + private handleEvent(evt: VenueEvent): void { + if ("instrumentId" in evt && evt.instrumentId !== undefined && evt.instrumentId !== this.instrument.id) { + return; + } + switch (evt.type) { + case "order-created": + if (evt.isOwn) this.ownOrders.set(evt.order.orderId, evt.order); + break; + case "order-cancelled": + if (evt.isOwn) this.ownOrders.delete(evt.orderId); + break; + case "order-updated": + if (evt.isOwn) { + const existing = this.ownOrders.get(evt.orderId); + if (existing) { + if (evt.newQuantity === 0n) this.ownOrders.delete(evt.orderId); + else existing.quantity = evt.newQuantity; + } + } + break; + case "order-matched": + if (evt.isOwn) { + this.logger.info({ makerOrderId: evt.makerOrderId }, "own order matched"); + } + break; + case "depth-changed": { + const map = evt.isBid ? this.bidDepth : this.askDepth; + if (evt.newQuantity === 0n) map.delete(evt.price); + else map.set(evt.price, evt.newQuantity); + break; + } + default: + break; + } + } +} diff --git a/market-maker/src/client.ts b/market-maker/src/client.ts new file mode 100644 index 0000000..7312e2a --- /dev/null +++ b/market-maker/src/client.ts @@ -0,0 +1,64 @@ +import { createPublicClient, createWalletClient, defineChain, http, webSocket } from "viem"; +import type { Chain, PublicClient, WalletClient, Transport } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import type { Account, Hex } from "viem"; +import { arbitrum, arbitrumSepolia, base, baseSepolia, hardhat as hardhatBase } from "viem/chains"; +import { ConfigError } from "./errors.ts"; + +export const hardhat = defineChain({ + ...hardhatBase, + contracts: { + ...hardhatBase.contracts, + multicall3: { + address: "0xcA11bde05977b3631167028862bE2a173976CA11" as `0x${string}`, + }, + }, +}); + +export const chainMapping: Record = { + "arbitrum-sepolia": arbitrumSepolia, + "base-sepolia": baseSepolia, + arbitrum, + base, + hardhat, +}; + +export function resolveChain(networkName: string): Chain { + const chain = chainMapping[networkName]; + if (!chain) { + throw new ConfigError(`Unsupported network: ${networkName}`); + } + return chain; +} + +export function createTransport(rpcUrl: string): Transport { + return rpcUrl.startsWith("ws") ? webSocket(rpcUrl) : http(rpcUrl); +} + +export interface NetworkClients { + publicClient: PublicClient; + chain: Chain; + transport: Transport; +} + +export function createNetworkClients(networkName: string, rpcUrl: string): NetworkClients { + const chain = resolveChain(networkName); + const transport = createTransport(rpcUrl); + const publicClient = createPublicClient({ transport, chain }); + return { publicClient, chain, transport }; +} + +export interface WalletClients { + account: Account; + walletClient: WalletClient; +} + +export function createWalletFromKey( + privateKey: Hex, + chain: Chain, + transport: Transport, +): WalletClients { + const account = privateKeyToAccount(privateKey); + const walletClient = createWalletClient({ account, transport, chain }); + return { account, walletClient }; +} diff --git a/market-maker/src/config.ts b/market-maker/src/config.ts new file mode 100644 index 0000000..b53527d --- /dev/null +++ b/market-maker/src/config.ts @@ -0,0 +1,200 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import yaml from "js-yaml"; +import { type Static, type StringOptions, type TUnsafe, Type } from "@sinclair/typebox"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import { ConfigError } from "./errors.ts"; +import type { VenueKind } from "./adapter.ts"; + +const TypeEthAddress = (opt?: StringOptions) => + Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]{40}$" }) as TUnsafe<`0x${string}`>; + +const TypeHex = (opt?: StringOptions) => + Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]+$" }) as TUnsafe<`0x${string}`>; + +/** + * ${VAR} expansion. Recursively walks strings in the parsed YAML and replaces + * ${NAME} with process.env.NAME. Throws if the variable is not set unless a + * default is provided via the `${NAME:-default}` syntax. + */ +function expandEnv(value: unknown, env: NodeJS.ProcessEnv): unknown { + if (typeof value === "string") { + return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_match, name, def) => { + const v = env[name]; + if (v !== undefined && v !== "") return v; + if (def !== undefined) return def; + throw new ConfigError(`Environment variable "${name}" is not set`); + }); + } + if (Array.isArray(value)) { + return value.map((v) => expandEnv(v, env)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = expandEnv(v, env); + } + return out; + } + return value; +} + +const walletSchema = Type.Object({ + privateKey: TypeHex(), +}); + +const networkSchema = Type.Object({ + name: Type.String(), + rpcUrl: Type.String(), + ethPriceFeed: Type.Optional(TypeEthAddress()), +}); + +const venueBase = Type.Object({ + kind: Type.Union([ + Type.Literal("perps"), + Type.Literal("futures"), + Type.Literal("options"), + ]), + address: TypeEthAddress(), + wallet: Type.String(), + eventsFromBlock: Type.Optional(Type.Number({ minimum: 0 })), +}); + +const pricingSchema = Type.Object({ + strategy: Type.Union([ + Type.Literal("effective-spread"), + Type.Literal("reservation-price"), + ]), + minSpreadBps: Type.Number({ minimum: 0 }), + volatilityMultiplier: Type.Number({ minimum: 0 }), + inventorySkewGamma: Type.Optional(Type.Number({ minimum: 0 })), + maxSkewTicks: Type.Number({ minimum: 0 }), + // reservation-price extras (optional; required at strategy load) + riskAversion: Type.Optional(Type.Number({ minimum: 0 })), + marginCallTimeSeconds: Type.Optional(Type.Number({ minimum: 0 })), +}); + +const sizingSchema = Type.Object({ + strategy: Type.Union([ + Type.Literal("linear"), + Type.Literal("geometric-taper"), + ]), + baseQuantity: Type.String(), // bigint-as-string + numLevelsPerSide: Type.Number({ minimum: 1 }), + // geometric-taper extras + taperRatio: Type.Optional(Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1 })), +}); + +const riskSchema = Type.Object({ + maxPositionSize: Type.String(), + maxUtilizationPct: Type.Number({ minimum: 0, maximum: 100, default: 80 }), + minCollateralBalance: Type.String(), + maxDailyLossUsd: Type.String(), + maxGasBudgetPerHourUsd: Type.String({ default: "50000000" }), + maxGasBudgetPerDayUsd: Type.String({ default: "500000000" }), + gasSpikeThresholdPct: Type.Number({ default: 200 }), + gasPenaltyBps: Type.Number({ default: 5 }), + urgentRequoteThresholdTicks: Type.Number({ default: 10 }), +}); + +const gasSchema = Type.Object({ + gasCapMultiplier: Type.Number({ default: 2.0 }), +}); + +const timingSchema = Type.Object({ + pollIntervalMs: Type.Number({ minimum: 100, default: 3000 }), + requoteThresholdTicks: Type.Number({ minimum: 0, default: 2 }), + requoteCooldownMs: Type.Number({ minimum: 0, default: 1000 }), + resyncIntervalMs: Type.Number({ minimum: 1000, default: 60000 }), +}); + +const healthSchema = Type.Object({ + port: Type.Number({ minimum: 0, default: 3001 }), +}); + +const rootSchema = Type.Object({ + nodeEnv: Type.String({ default: "development" }), + commitHash: Type.String({ default: "unknown" }), + logLevel: Type.String({ default: "info" }), + dryRun: Type.Boolean({ default: false }), + wallets: Type.Record(Type.String(), walletSchema), + network: networkSchema, + venue: venueBase, + pricing: pricingSchema, + sizing: sizingSchema, + risk: riskSchema, + gas: gasSchema, + timing: timingSchema, + health: healthSchema, +}); + +export type MakerConfig = Static; + +/** Parse a YAML file, expand ${VAR} tokens, validate against schema. */ +export function loadConfig(opts: { + path?: string; + env?: NodeJS.ProcessEnv; +} = {}): MakerConfig { + const env = opts.env ?? process.env; + const configPath = opts.path ?? env.MAKER_CONFIG ?? parseConfigArg(process.argv); + if (!configPath) { + throw new ConfigError( + "No config path provided. Use --config or set MAKER_CONFIG env var.", + ); + } + + const abs = resolve(process.cwd(), configPath); + let raw: string; + try { + raw = readFileSync(abs, "utf8"); + } catch (err) { + throw new ConfigError(`Failed to read config at ${abs}: ${(err as Error).message}`); + } + + const parsed = yaml.load(raw); + const expanded = expandEnv(parsed, env); + + const ajv = new Ajv.default({ + allErrors: true, + useDefaults: true, + coerceTypes: false, + }); + addFormats.default(ajv); + const validate = ajv.compile(rootSchema); + if (!validate(expanded)) { + const msgs = (validate.errors ?? []) + .map((e) => `${e.instancePath || ""} ${e.message ?? ""}`) + .join("; "); + throw new ConfigError(`Config validation failed: ${msgs}`); + } + + const cfg = expanded as MakerConfig; + + if (!cfg.wallets[cfg.venue.wallet]) { + throw new ConfigError( + `venue.wallet "${cfg.venue.wallet}" is not declared in wallets map`, + ); + } + + return cfg; +} + +function parseConfigArg(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--config" && argv[i + 1]) return argv[i + 1]; + if (argv[i].startsWith("--config=")) return argv[i].slice("--config=".length); + } + return undefined; +} + +/** Parse a bigint-as-string value, throwing ConfigError on failure. */ +export function configBigint(value: string, field: string): bigint { + try { + return BigInt(value); + } catch { + throw new ConfigError(`Invalid bigint value for ${field}: "${value}"`); + } +} + +export type { VenueKind }; diff --git a/market-maker/src/errSerializer.ts b/market-maker/src/errSerializer.ts new file mode 100644 index 0000000..fe0471c --- /dev/null +++ b/market-maker/src/errSerializer.ts @@ -0,0 +1,38 @@ +import pino from "pino"; +import type { ErrorInfo } from "./errors.ts"; + +function stripAbiRecursive(value: T): T { + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => stripAbiRecursive(item)) as T; + } + const obj = value as Record; + const out: Record = {}; + for (const key of Object.keys(obj)) { + if (key === "abi") continue; + out[key] = stripAbiRecursive(obj[key]); + } + return out as T; +} + +/** + * Serializes an error for logs and API: errWithCause (includes cause) then + * strips `abi` recursively at every depth. Pino redact cannot match arbitrary + * depth (each `*` is one level only), so we strip in the serializer instead. + */ +export function serializeError(err: unknown): Record { + if (err === null || typeof err !== "object" || !(err instanceof Error)) { + return { raw: err }; + } + const serialized = pino.stdSerializers.errWithCause(err) as Record; + return stripAbiRecursive(serialized) as Record; +} + +export function toErrorInfo(err: unknown): ErrorInfo { + if (!(err instanceof Error)) { + return { message: String(err) }; + } + return serializeError(err) as unknown as ErrorInfo; +} diff --git a/market-maker/src/errors.ts b/market-maker/src/errors.ts new file mode 100644 index 0000000..c2cdc91 --- /dev/null +++ b/market-maker/src/errors.ts @@ -0,0 +1,19 @@ +export class NotImplementedError extends Error { + constructor(message = "not implemented") { + super(message); + this.name = "NotImplementedError"; + } +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +/** Structured error payload used in /health and risk halt reasons. */ +export interface ErrorInfo { + message: string; + [key: string]: unknown; +} diff --git a/market-maker/src/gasTracker.ts b/market-maker/src/gasTracker.ts new file mode 100644 index 0000000..aca7046 --- /dev/null +++ b/market-maker/src/gasTracker.ts @@ -0,0 +1,161 @@ +import type { Address, PublicClient } from "viem"; +import type pino from "pino"; +import Fraction from "fraction.js"; +import { RollingWindow } from "./math.ts"; + +export interface GasTrackerConfig { + /** Chainlink aggregator address; if absent, ethPriceUsd stays 0. */ + ethPriceFeedAddress?: Address; + gasSpikeThresholdPct: number; + gasCapMultiplier: number; +} + +const aggregatorV3InterfaceAbi = [ + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "latestRoundData", + outputs: [ + { internalType: "uint80", name: "roundId", type: "uint80" }, + { internalType: "int256", name: "answer", type: "int256" }, + { internalType: "uint256", name: "startedAt", type: "uint256" }, + { internalType: "uint256", name: "updatedAt", type: "uint256" }, + { internalType: "uint80", name: "answeredInRound", type: "uint80" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +export class GasTracker { + currentGasPrice = 0n; + /** Fraction representation of median gas price (bigint is nicer but RollingWindow gives us bigint median so we store bigint). */ + medianGasPrice = 0n; + /** Gas spike as Fraction (percentage). */ + gasSpikePct: Fraction = new Fraction(0n); + isGasSpiking = false; + + estimatedCreateGas = 300_000n; + estimatedCancelGas = 100_000n; + + /** Current ETH price scaled to 6-decimal USDC terms. */ + ethPriceUsd = 0n; + + private readonly publicClient: PublicClient; + private readonly config: GasTrackerConfig; + private readonly gasWindow: RollingWindow; + private readonly logger: pino.Logger; + + constructor(publicClient: PublicClient, config: GasTrackerConfig, logger: pino.Logger) { + this.publicClient = publicClient; + this.config = config; + this.gasWindow = new RollingWindow(60); + this.logger = logger.child({ component: "gas" }); + } + + async update(): Promise { + this.currentGasPrice = await this.publicClient.getGasPrice(); + this.gasWindow.push(this.currentGasPrice); + this.medianGasPrice = this.gasWindow.median(); + + if (this.medianGasPrice > 0n) { + const diff = this.currentGasPrice - this.medianGasPrice; + this.gasSpikePct = new Fraction(diff, this.medianGasPrice).mul(new Fraction(100n)); + } else { + this.gasSpikePct = new Fraction(0n); + } + + this.isGasSpiking = this.gasSpikePct.compare(new Fraction(this.config.gasSpikeThresholdPct)) > 0; + + if (this.config.ethPriceFeedAddress) { + await this.updateEthPrice(); + } + + this.logger.debug( + { gasPrice: this.currentGasPrice.toString(), spiking: this.isGasSpiking }, + "gas tick", + ); + } + + /** + * Cost of `gasUnits` gas at the current price, expressed in 6-decimal USDC units. + * Returns 0n if ETH price is unknown. + */ + gasCostUsd(gasUnits: bigint): bigint { + if (this.ethPriceUsd === 0n) return 0n; + return (gasUnits * this.currentGasPrice * this.ethPriceUsd) / 10n ** 18n; + } + + get placeCostUsd(): bigint { + return this.gasCostUsd(this.estimatedCreateGas); + } + + get cancelCostUsd(): bigint { + return this.gasCostUsd(this.estimatedCancelGas); + } + + get roundTripCostUsd(): bigint { + return this.cancelCostUsd + this.placeCostUsd; + } + + requoteCycleCostUsd(totalOrders: number): bigint { + return BigInt(totalOrders) * this.roundTripCostUsd; + } + + cappedGasPrice(): bigint { + if (this.medianGasPrice === 0n) return this.currentGasPrice; + // cap = median * capMultiplier; use bigint arithmetic with 1000-precision + const multPrecision = 1000n; + const mult = BigInt(Math.round(this.config.gasCapMultiplier * 1000)); + const cap = (this.medianGasPrice * mult) / multPrecision; + return this.currentGasPrice < cap ? cap : this.currentGasPrice; + } + + /** Calibrate gas estimates against a candidate transaction. Adapters provide the tx. */ + async calibrate(estimator: () => Promise): Promise { + try { + const gas = await estimator(); + if (gas > 0n) { + this.estimatedCreateGas = gas; + this.logger.info({ createGas: gas.toString() }, "calibrated createOrder gas"); + } + } catch (err) { + this.logger.warn({ err }, "gas calibration failed, using defaults"); + } + } + + private async updateEthPrice(): Promise { + try { + const [[, answer], decimals] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { + address: this.config.ethPriceFeedAddress!, + abi: aggregatorV3InterfaceAbi, + functionName: "latestRoundData", + }, + { + address: this.config.ethPriceFeedAddress!, + abi: aggregatorV3InterfaceAbi, + functionName: "decimals", + }, + ], + }); + if (answer > 0n) { + this.ethPriceUsd = scaleDecimals(answer, BigInt(decimals), 6n); + } + } catch { + this.logger.warn("ETH price feed read failed"); + } + } +} + +function scaleDecimals(value: bigint, from: bigint, to: bigint): bigint { + return from >= to ? value / 10n ** (from - to) : value * 10n ** (to - from); +} diff --git a/market-maker/src/healthcheck.ts b/market-maker/src/healthcheck.ts new file mode 100644 index 0000000..23e3d56 --- /dev/null +++ b/market-maker/src/healthcheck.ts @@ -0,0 +1,207 @@ +import { createServer } from "node:http"; +import type { Server, ServerResponse } from "node:http"; +import type pino from "pino"; +import type { OracleTracker } from "./oracleTracker.ts"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { BookTracker } from "./bookTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type Fraction from "fraction.js"; +import type { RiskManager } from "./riskManager.ts"; +import type { MakerConfig } from "./config.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export interface ExecutorStats { + ordersPlaced: number; + ordersCancelled: number; + reconcileCount: number; +} + +export interface HealthCheckOptions { + port: number; + config: MakerConfig; + oracle: OracleTracker; + inventory: InventoryManager; + book: BookTracker; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; +} + +/** + * HTTP endpoint exposing health, status, and runtime config. + * + * GET /health → JSON snapshot of all trackers and config (sanitised) + * POST /stop → pause the main loop, cancel resting orders (via onStop) + * POST /start → resume the main loop (via onStart) + * + * The bot's main loop checks `paused` and skips ticks while true. + */ +export class HealthCheck { + private server: Server | null = null; + private startedAt = Date.now(); + + tickCount = 0; + lastTickAt = 0; + executorStats: ExecutorStats | null = null; + walletAddress = ""; + status: "initializing" | "init-error" | "running" | "error" | "stopped" = "initializing"; + lastError: ErrorInfo | null = null; + paused = false; + + onStop: (() => Promise) | null = null; + onStart: (() => Promise) | null = null; + + private readonly port: number; + private readonly config: MakerConfig; + private readonly oracle: OracleTracker; + private readonly inventory: InventoryManager; + private readonly book: BookTracker; + private readonly gas: GasTracker; + private readonly risk: RiskManager; + private readonly logger: pino.Logger; + + constructor(opts: HealthCheckOptions) { + this.port = opts.port; + this.config = opts.config; + this.oracle = opts.oracle; + this.inventory = opts.inventory; + this.book = opts.book; + this.gas = opts.gas; + this.risk = opts.risk; + this.logger = opts.logger; + } + + start(): Promise { + return new Promise((resolve) => { + this.startedAt = Date.now(); + this.server = createServer((req, res) => { + try { + if (req.method === "POST" && req.url === "/stop") return this.handleStop(res); + if (req.method === "POST" && req.url === "/start") return this.handleStart(res); + if (req.method === "GET" && req.url === "/health") return this.handleHealth(res); + res.writeHead(404); + res.end(); + } catch (err) { + this.logger.error({ err }, "server error"); + res.writeHead(500); + res.end(); + } + }); + + const logger = this.logger; + this.server.listen(this.port, () => { + logger.info({ url: `http://localhost:${this.port}/health` }, "health endpoint started"); + resolve(); + }); + }); + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (!this.server) return resolve(); + this.server.close((err) => { + this.server = null; + if (err) reject(err); + else resolve(); + }); + }); + } + + private handleHealth(res: ServerResponse): void { + const body = JSON.stringify({ + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + config: { + nodeEnv: this.config.nodeEnv, + commitHash: this.config.commitHash, + logLevel: this.config.logLevel, + dryRun: this.config.dryRun, + network: this.config.network.name, + venue: { kind: this.config.venue.kind, address: this.config.venue.address }, + pricing: this.config.pricing, + sizing: this.config.sizing, + risk: this.config.risk, + gas: this.config.gas, + timing: this.config.timing, + }, + market: { + oraclePrice: this.oracle.currentPrice.toString(), + volatility: fractionToNumber(this.oracle.volatility), + bestBid: this.book.bestBid.toString(), + bestAsk: this.book.bestAsk.toString(), + ownOrders: this.book.ownOrders.size, + }, + inventory: { + netPosition: this.inventory.netQuantity.toString(), + collateralBalance: this.inventory.collateralBalance.toString(), + nativeBalance: this.inventory.nativeBalance.toString(), + walletTokenBalance: this.inventory.walletTokenBalance.toString(), + inventorySkew: fractionToNumber(this.inventory.inventorySkew), + utilizationPct: this.inventory.utilizationPct, + }, + gas: { + gasGwei: (Number(this.gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: this.gas.isGasSpiking, + gasSpikePct: fractionToNumber(this.gas.gasSpikePct).toFixed(0), + }, + risk: { + throttled: this.risk.throttled, + throttleReason: this.risk.throttleReason, + cumulativeGasCostUsd: this.risk.cumulativeGasCostUsd.toString(), + }, + stats: { + tickCount: this.tickCount, + lastTickAt: this.lastTickAt, + ordersPlaced: this.executorStats?.ordersPlaced ?? 0, + ordersCancelled: this.executorStats?.ordersCancelled ?? 0, + reconcileCount: this.executorStats?.reconcileCount ?? 0, + }, + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); + } + + private handleStop(res: ServerResponse): void { + if (this.paused) return this.respondOk(res); + this.paused = true; + this.status = "stopped"; + this.lastError = null; + + if (!this.onStop) return this.respondOk(res); + this.onStop() + .then(() => this.respondOk(res)) + .catch((err) => { + this.logger.error({ err }, "onStop callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "stop callback failed" })); + }); + } + + private handleStart(res: ServerResponse): void { + if (!this.paused) return this.respondOk(res); + this.paused = false; + this.status = "running"; + this.lastError = null; + + if (!this.onStart) return this.respondOk(res); + this.onStart() + .then(() => this.respondOk(res)) + .catch((err) => { + this.logger.error({ err }, "onStart callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "start callback failed" })); + }); + } + + private respondOk(res: ServerResponse): void { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, status: this.status })); + } +} + +function fractionToNumber(value: Fraction): number { + // diagnostic only — never used in trading math + return (Number(value.s) * Number(value.n)) / Number(value.d); +} diff --git a/market-maker/src/helpers.ts b/market-maker/src/helpers.ts new file mode 100644 index 0000000..1de4ddd --- /dev/null +++ b/market-maker/src/helpers.ts @@ -0,0 +1,166 @@ +/** + * Venue-agnostic helper functions used primarily by the futures adapter + * and its historical warm-up path (scanning past on-chain events for vol). + * + * All functions operate on bigint prices and plain numbers for timestamps. + * No viem or on-chain dependencies; fully unit-testable in isolation. + */ + +import Fraction from "fraction.js"; +import { ln, sqrt } from "./rational.ts"; + +// ─── Order delta ────────────────────────────────────────────────────────────── + +export interface PricedOrder { + price: bigint; + /** Signed quantity: positive = buy/long, negative = sell/short. */ + qty: bigint; +} + +/** + * Calculate the minimal set of orders needed to transition from `currentOrders` + * to `modelledOrders`. Orders at the same price offset each other: a +5 and a -5 + * at the same price produce a zero diff and are omitted. + * + * Returns orders sorted by price ascending. + */ +export function calculateOrders( + modelledOrders: PricedOrder[], + currentOrders: PricedOrder[], +): PricedOrder[] { + const modelledByPrice = new Map(); + for (const o of modelledOrders) { + modelledByPrice.set(o.price, (modelledByPrice.get(o.price) ?? 0n) + o.qty); + } + + const currentByPrice = new Map(); + for (const o of currentOrders) { + currentByPrice.set(o.price, (currentByPrice.get(o.price) ?? 0n) + o.qty); + } + + const allPrices = new Set([...modelledByPrice.keys(), ...currentByPrice.keys()]); + const result: PricedOrder[] = []; + + for (const price of allPrices) { + const diff = (modelledByPrice.get(price) ?? 0n) - (currentByPrice.get(price) ?? 0n); + if (diff !== 0n) result.push({ price, qty: diff }); + } + + result.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); + return result; +} + +// ─── Resample ───────────────────────────────────────────────────────────────── + +export interface TimedPrice { + /** Milliseconds since epoch (same as Date.now() convention). */ + date: number; + price: bigint; +} + +/** + * Resample irregular price ticks into fixed-interval close prices. + * + * "Close" = last observed price in each bucket. + * Missing buckets are filled with LOCF (last observation carried forward). + * + * @param prices - Raw price ticks in any order. + * @param intervalMs - Bucket width in ms (default 1 hour). + */ +export function resampleHourlyClose( + prices: TimedPrice[], + intervalMs = 60 * 60 * 1000, +): TimedPrice[] { + const pts = (prices ?? []).slice().sort((a, b) => a.date - b.date); + if (pts.length === 0) return []; + + const bucketStart = (t: number) => Math.floor(t / intervalMs) * intervalMs; + + const closeByBucket = new Map(); + for (const p of pts) { + closeByBucket.set(bucketStart(p.date), p.price); + } + + const start = bucketStart(pts[0].date); + const end = bucketStart(pts[pts.length - 1].date); + const result: TimedPrice[] = []; + + let last: bigint | null = null; + for (let h = start; h <= end; h += intervalMs) { + const price: bigint | null = closeByBucket.has(h) ? (closeByBucket.get(h) as bigint) : last; + if (price != null) { + result.push({ date: h, price }); + last = price; + } + } + return result; +} + +// ─── Realized volatility ────────────────────────────────────────────────────── + +export interface VolatilityResult { + /** Stddev of log returns per sample step. 0 if fewer than 2 valid returns. */ + sigmaPerStep: number; +} + +/** + * Realized volatility from a price series: stddev of log returns. + * + * Uses Fraction arithmetic (via rational.ts ln/sqrt) for precision. + * Returns `{ sigmaPerStep: 0 }` for fewer than 2 valid log-return pairs. + * Returns `{ sigmaPerStep: NaN }` when sample=true and exactly 1 return + * (matches the original futures implementation: variance = 0/0 = NaN). + * + * Input prices must be positive bigints; dates must be positive finite numbers. + * Input is sorted by date before processing. + * + * @param prices - Price ticks with timestamps. + * @param sample - Use sample variance N−1 denominator (default true). + * @param precisionBits - Precision for ln/sqrt (default 48). + */ +export function realizedVolatility( + prices: TimedPrice[], + sample = true, + precisionBits = 48, +): VolatilityResult { + for (const p of prices ?? []) { + if (p.price <= 0n) throw new Error(`Invalid p.price: price=${p.price}, date=${p.date}`); + if (!Number.isFinite(p.date) || p.date <= 0) { + throw new Error(`Invalid p.date: price=${p.price}, date=${p.date}`); + } + } + + const pts = (prices ?? []).slice().sort((a, b) => a.date - b.date); + if (pts.length < 2) return { sigmaPerStep: 0 }; + + const returns: Fraction[] = []; + for (let i = 1; i < pts.length; i++) { + const prev = pts[i - 1].price; + const curr = pts[i].price; + if (prev > 0n && curr > 0n) { + returns.push(ln(new Fraction(curr, prev), precisionBits)); + } + } + + if (returns.length === 0) return { sigmaPerStep: 0 }; + + // Exactly 1 return with sample variance → division by (1-1)=0 → NaN + if (sample && returns.length === 1) return { sigmaPerStep: Number.NaN }; + + let sum = new Fraction(0n); + for (const r of returns) sum = sum.add(r); + const mean = sum.div(new Fraction(BigInt(returns.length))); + + let varSum = new Fraction(0n); + for (const r of returns) { + const d = r.sub(mean); + varSum = varSum.add(d.mul(d)); + } + + const denom = BigInt(sample ? returns.length - 1 : returns.length); + const variance = varSum.div(new Fraction(denom)); + const sigmaFrac = sqrt(variance, precisionBits); + + const magnitude = Number(sigmaFrac.n) / Number(sigmaFrac.d); + return { sigmaPerStep: sigmaFrac.s < 0 ? -magnitude : magnitude }; +} diff --git a/market-maker/src/index.ts b/market-maker/src/index.ts new file mode 100644 index 0000000..807e5ec --- /dev/null +++ b/market-maker/src/index.ts @@ -0,0 +1,36 @@ +export * from "./adapter.ts"; +export * from "./bookTracker.ts"; +export * from "./client.ts"; +export * from "./config.ts"; +export * from "./errors.ts"; +export * from "./errSerializer.ts"; +export * from "./gasTracker.ts"; +export * from "./healthcheck.ts"; +export * from "./inventoryManager.ts"; +export * from "./math.ts"; +export * from "./oracleTracker.ts"; +export * from "./orderExecutor.ts"; +export * from "./quoter.ts"; +export * from "./rational.ts"; +export * from "./registry.ts"; +export * from "./riskManager.ts"; +export * from "./wallet.ts"; +export { + computeMidQuote, + type EffectiveSpreadConfig, + type MidQuote, +} from "./pricing/effectiveSpread.ts"; +export { + computeReservationMidQuote, + type ReservationPriceConfig, +} from "./pricing/reservationPrice.ts"; +export { linearSizes } from "./sizing/linear.ts"; +export { geometricTaperSizes } from "./sizing/geometricTaper.ts"; +export { + calculateOrders, + resampleHourlyClose, + realizedVolatility, + type PricedOrder, + type TimedPrice, + type VolatilityResult, +} from "./helpers.ts"; diff --git a/market-maker/src/inventoryManager.ts b/market-maker/src/inventoryManager.ts new file mode 100644 index 0000000..27c151c --- /dev/null +++ b/market-maker/src/inventoryManager.ts @@ -0,0 +1,102 @@ +import type pino from "pino"; +import Fraction from "fraction.js"; +import type { InstrumentAdapter } from "./adapter.ts"; +import { bigAbs } from "./math.ts"; + +export interface InventoryManagerConfig { + /** Max absolute net position; used for skew normalisation. */ + maxPositionSize: bigint; +} + +/** + * Tracks the MM's position on a single instrument plus the venue's collateral snapshot. + * + * One inventory manager per instrument. Collateral is shared across all instruments on + * the same venue, so multi-instrument deployments would aggregate margin separately. + */ +export class InventoryManager { + netQuantity = 0n; + entryPrice = 0n; + + collateralBalance = 0n; + maintenanceMargin = 0n; + walletTokenBalance = 0n; + nativeBalance = 0n; + collateralTokenAddress: `0x${string}` | null = null; + + /** Margin available to back new exposure (collateralBalance − maintenanceMargin). */ + availableMargin = 0n; + /** Maintenance margin / collateral as a Fraction in [0, 1]. */ + utilization: Fraction = new Fraction(0n); + /** netQuantity / maxPositionSize as a Fraction in [-1, 1]. */ + inventorySkew: Fraction = new Fraction(0n); + + private readonly instrument: InstrumentAdapter; + private readonly cfg: InventoryManagerConfig; + private readonly logger: pino.Logger; + + constructor(instrument: InstrumentAdapter, cfg: InventoryManagerConfig, logger: pino.Logger) { + this.instrument = instrument; + this.cfg = cfg; + this.logger = logger.child({ component: "inventory", instrument: instrument.id }); + } + + async update(): Promise { + const [pos, collateral] = await Promise.all([ + this.instrument.getPosition(), + this.instrument.venue.getCollateral(), + ]); + + this.netQuantity = pos.netQuantity; + this.entryPrice = pos.entryPrice; + + this.collateralBalance = collateral.balance; + this.maintenanceMargin = collateral.maintenanceMargin; + this.walletTokenBalance = collateral.walletTokenBalance; + this.nativeBalance = collateral.nativeBalance; + this.collateralTokenAddress = collateral.collateralTokenAddress; + + this.availableMargin = + this.collateralBalance > this.maintenanceMargin + ? this.collateralBalance - this.maintenanceMargin + : 0n; + + this.utilization = + this.collateralBalance > 0n + ? new Fraction(this.maintenanceMargin, this.collateralBalance) + : new Fraction(0n); + + const maxPos = this.cfg.maxPositionSize; + if (maxPos > 0n) { + const raw = new Fraction(this.netQuantity, maxPos); + const one = new Fraction(1n); + const negOne = new Fraction(-1n); + this.inventorySkew = raw.compare(one) > 0 ? one : raw.compare(negOne) < 0 ? negOne : raw; + } else { + this.inventorySkew = new Fraction(0n); + } + + this.logger.debug( + { + net: this.netQuantity.toString(), + balance: this.collateralBalance.toString(), + skew: this.inventorySkew.valueOf(), + utilization: this.utilization.valueOf(), + }, + "inventory tick", + ); + } + + get hasPosition(): boolean { + return this.netQuantity !== 0n; + } + + get absPosition(): bigint { + return bigAbs(this.netQuantity); + } + + /** Utilization as integer percent in [0, 100]. */ + get utilizationPct(): number { + return Number(this.utilization.mul(new Fraction(100n)).round().valueOf()); + } +} diff --git a/market-maker/src/main.ts b/market-maker/src/main.ts new file mode 100644 index 0000000..dee0b17 --- /dev/null +++ b/market-maker/src/main.ts @@ -0,0 +1,305 @@ +import pino from "pino"; +import { + BookTracker, + GasTracker, + HealthCheck, + InventoryManager, + OracleTracker, + OrderExecutor, + Quoter, + RiskManager, + WalletRegistry, + configBigint, + createAdapter, + createNetworkClients, + loadConfig, + serializeError, + toErrorInfo, +} from "./index.ts"; +import "./adapters/perps/index.ts"; +import "./adapters/futures/index.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main(): Promise { + const config = loadConfig(); + + const logger = pino({ + level: config.logLevel, + serializers: { err: serializeError }, + }); + + logger.info( + { + venue: config.venue.kind, + address: config.venue.address, + network: config.network.name, + dryRun: config.dryRun, + }, + "starting market maker", + ); + + const network = createNetworkClients(config.network.name, config.network.rpcUrl); + const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + + const venue = await createAdapter(config.venue.kind, { + config, + wallets, + network, + logger, + }); + + const instruments = await venue.listInstruments(); + if (instruments.length === 0) { + throw new Error(`venue "${config.venue.kind}" returned no instruments`); + } + const instrument = instruments[0]; + if (instruments.length > 1) { + logger.warn( + { count: instruments.length, picking: instrument.id }, + "multi-instrument support is not yet wired in apps/maker; using first instrument only", + ); + } + + const mmAddress = venue.wallet.account.address; + logger.info({ wallet: venue.wallet.name, address: mmAddress }, "wallet ready"); + + const oracle = new OracleTracker(instrument, logger); + const gas = new GasTracker( + network.publicClient, + { + ethPriceFeedAddress: config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, + logger, + ); + const risk = new RiskManager( + { + maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), + maxUtilizationPct: config.risk.maxUtilizationPct, + minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), + maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), + maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), + maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), + }, + inventory, + gas, + oracle, + logger, + ); + const book = new BookTracker( + instrument, + { + resyncIntervalMs: config.timing.resyncIntervalMs, + snapshotDepth: 200, + }, + logger, + ); + + const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); + + const quoter = new Quoter( + instrument, + { + pricing: config.pricing.strategy === "reservation-price" + ? { + strategy: "reservation-price" as const, + riskAversion: config.pricing.riskAversion ?? 0.1, + marginCallTimeSeconds: config.pricing.marginCallTimeSeconds ?? 3600, + minSpreadBps: config.pricing.minSpreadBps, + volatilityMultiplier: config.pricing.volatilityMultiplier, + gasPenaltyBps: config.risk.gasPenaltyBps, + } + : { + strategy: "effective-spread" as const, + minSpreadBps: config.pricing.minSpreadBps, + volatilityMultiplier: config.pricing.volatilityMultiplier, + inventorySkewGamma: config.pricing.inventorySkewGamma ?? 0, + gasPenaltyBps: config.risk.gasPenaltyBps, + }, + sizing: + config.sizing.strategy === "geometric-taper" + ? { + strategy: "geometric-taper", + baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + taperRatio: config.sizing.taperRatio ?? 0.5, + } + : { + strategy: "linear", + baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + }, + maxSkewTicks: config.pricing.maxSkewTicks, + }, + oracle, + gas, + inventory, + risk, + logger, + ); + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + requoteThresholdTicks: config.timing.requoteThresholdTicks, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + dryRun: config.dryRun, + }, + quoter, + book, + gas, + risk, + oracle, + logger, + ); + const health = new HealthCheck({ + port: config.health.port, + config, + oracle, + inventory, + book, + gas, + risk, + logger, + }); + + health.executorStats = executor.stats; + health.walletAddress = mmAddress; + + health.onStop = async () => { + logger.info("stop requested via API, cancelling orders"); + await executor.cancelAll(); + book.stop(); + }; + health.onStart = async () => { + logger.info("start requested via API, re-initializing"); + await book.start(); + await oracle.update(); + await gas.update(); + await inventory.update(); + }; + + await health.start(); + + const BASE_ERROR_DELAY_MS = 5_000; + const MAX_ERROR_DELAY_MS = 3 * 60_000; + + for (let attempt = 1; ; attempt++) { + try { + await quoter.initialize(); + await gas.calibrate(() => instrument.estimateCreateGas(mmAddress)); + await book.start(); + await oracle.update(); + await gas.update(); + await inventory.update(); + risk.initialize(); + health.status = "running"; + health.lastError = null; + break; + } catch (err) { + health.status = "init-error"; + health.lastError = toErrorInfo(err); + const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); + logger.warn({ err, attempt, retryInMs: delay }, "initialization failed, retrying"); + await sleep(delay); + } + } + + logger.info("initialization complete, entering main loop"); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down…"); + try { + await executor.cancelAll(); + } catch (err) { + logger.error({ err }, "failed to cancel orders during shutdown"); + } + book.stop(); + await health.stop(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + let consecutiveErrors = 0; + while (!shuttingDown) { + if (health.paused) { + await sleep(config.timing.pollIntervalMs); + continue; + } + + try { + await oracle.update(); + await gas.update(); + await book.refresh(); + await inventory.update(); + + if (inventory.walletTokenBalance > 0n && config.nodeEnv === "production") { + try { + await venue.topUpCollateral(inventory.walletTokenBalance); + await inventory.update(); + } catch (err) { + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "failed to top up collateral"); + } + } + + logger.info( + { + oracle: oracle.currentPrice.toString(), + bid: book.bestBid.toString(), + ask: book.bestAsk.toString(), + pos: inventory.netQuantity.toString(), + collateralBalance: inventory.collateralBalance.toString(), + orders: book.ownOrders.size, + }, + "tick", + ); + + const ok = risk.check(); + if (!ok) { + health.status = "error"; + health.lastError = risk.haltReason; + consecutiveErrors++; + try { + await executor.cancelAll(); + } catch (err) { + health.lastError = toErrorInfo(err); + logger.error({ err }, "failed to cancel orders after risk halt"); + } + } else { + const desired = quoter.computeQuotes(); + await executor.reconcile(desired); + health.status = "running"; + health.lastError = null; + consecutiveErrors = 0; + } + } catch (err) { + consecutiveErrors++; + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "tick error"); + } + + health.tickCount++; + health.lastTickAt = Date.now(); + const delay = + consecutiveErrors > 0 + ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) + : config.timing.pollIntervalMs; + await sleep(delay); + } +} + +main(); diff --git a/market-maker/src/math.ts b/market-maker/src/math.ts new file mode 100644 index 0000000..5822859 --- /dev/null +++ b/market-maker/src/math.ts @@ -0,0 +1,151 @@ +import Fraction from "fraction.js"; +import { ln, sqrt } from "./rational.ts"; + +export const QUANTITY_DECIMALS = 6; +export const QUANTITY_SCALE = 10n ** BigInt(QUANTITY_DECIMALS); +export const BPS_SCALE = 10_000n; + +/** Round price DOWN to nearest tick (for bids). */ +export function roundDownToTick(price: bigint, tick: bigint): bigint { + return (price / tick) * tick; +} + +/** Round price UP to nearest tick (for asks). */ +export function roundUpToTick(price: bigint, tick: bigint): bigint { + const remainder = price % tick; + return remainder === 0n ? price : price + tick - remainder; +} + +/** Round price to nearest tick (ties up). */ +export function roundToTick(price: bigint, tick: bigint): bigint { + const remainder = price % tick; + if (remainder === 0n) return price; + return remainder * 2n >= tick ? price + (tick - remainder) : price - remainder; +} + +/** Notional value: price * absQuantity / 10^QUANTITY_DECIMALS. */ +export function calculateNotional(price: bigint, absQuantity: bigint): bigint { + const q = bigAbs(absQuantity); + return (price * q) / QUANTITY_SCALE; +} + +/** Apply basis-point offset to a price: price * (BPS_SCALE +/- bps) / BPS_SCALE. */ +export function applyBps(price: bigint, bps: bigint): bigint { + return (price * (BPS_SCALE + bps)) / BPS_SCALE; +} + +/** Absolute value for bigint. */ +export function bigAbs(v: bigint): bigint { + return v < 0n ? -v : v; +} + +/** Min / max for bigint. */ +export const bigMin = (a: bigint, b: bigint) => (a < b ? a : b); +export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); + +/** + * Rolling window of bigint samples. Computes: + * - realized volatility = stddev of log returns (Fraction-precise) + * - median (bigint) + * + * `precisionBits` controls the precision used for the internal `ln` and `sqrt` + * approximations when computing volatility; default is plenty for vol estimation. + */ +export class RollingWindow { + private readonly samples: bigint[] = []; + private readonly maxSize: number; + private readonly precisionBits: number; + + constructor(maxSize: number, precisionBits = 64) { + this.maxSize = maxSize; + this.precisionBits = precisionBits; + } + + push(value: bigint): void { + this.samples.push(value); + if (this.samples.length > this.maxSize) { + this.samples.shift(); + } + } + + get length(): number { + return this.samples.length; + } + + latest(): bigint | undefined { + return this.samples.length > 0 ? this.samples[this.samples.length - 1] : undefined; + } + + /** + * Realized volatility as stddev of log returns, returned as Fraction. + * Returns 0 if fewer than 3 samples or all returns are degenerate. + */ + volatility(): Fraction { + if (this.samples.length < 3) return new Fraction(0n); + + const returns: Fraction[] = []; + for (let i = 1; i < this.samples.length; i++) { + const prev = this.samples[i - 1]; + const curr = this.samples[i]; + if (prev > 0n && curr > 0n) { + // r = ln(curr / prev) = ln(curr) - ln(prev) + const ratio = new Fraction(curr, prev); + returns.push(ln(ratio, this.precisionBits)); + } + } + + if (returns.length < 2) return new Fraction(0n); + + let sum = new Fraction(0n); + for (const r of returns) sum = sum.add(r); + const mean = sum.div(new Fraction(BigInt(returns.length))); + + let varSum = new Fraction(0n); + for (const r of returns) { + const d = r.sub(mean); + varSum = varSum.add(d.mul(d)); + } + const variance = varSum.div(new Fraction(BigInt(returns.length - 1))); + return sqrt(variance, this.precisionBits); + } + + /** Median of samples (bigint). */ + median(): bigint { + if (this.samples.length === 0) return 0n; + const sorted = [...this.samples].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[mid]; + return (sorted[mid - 1] + sorted[mid]) / 2n; + } +} + +/** + * Rolling budget tracker: sums amounts in a sliding time window. + * Used for gas budget enforcement (hourly / daily). + */ +export class RollingBudget { + private readonly entries: Array<{ timestamp: number; amount: bigint }> = []; + private readonly windowMs: number; + + constructor(windowMs: number) { + this.windowMs = windowMs; + } + + add(amount: bigint, now: number = Date.now()): void { + this.entries.push({ timestamp: now, amount }); + } + + total(now: number = Date.now()): bigint { + this.prune(now); + let sum = 0n; + for (const e of this.entries) sum += e.amount; + return sum; + } + + private prune(now: number): void { + const cutoff = now - this.windowMs; + while (this.entries.length > 0 && this.entries[0].timestamp < cutoff) { + this.entries.shift(); + } + } +} diff --git a/market-maker/src/oracleTracker.ts b/market-maker/src/oracleTracker.ts new file mode 100644 index 0000000..941426f --- /dev/null +++ b/market-maker/src/oracleTracker.ts @@ -0,0 +1,38 @@ +import type pino from "pino"; +import Fraction from "fraction.js"; +import type { InstrumentAdapter } from "./adapter.ts"; +import { RollingWindow } from "./math.ts"; + +export interface OracleTrackerConfig { + windowSize?: number; + precisionBits?: number; +} + +export class OracleTracker { + currentPrice = 0n; + /** Realized volatility as a Fraction (stddev of log returns). */ + volatility: Fraction = new Fraction(0n); + + private readonly instrument: InstrumentAdapter; + private readonly priceWindow: RollingWindow; + private readonly logger: pino.Logger; + + constructor(instrument: InstrumentAdapter, logger: pino.Logger, cfg: OracleTrackerConfig = {}) { + this.instrument = instrument; + this.priceWindow = new RollingWindow(cfg.windowSize ?? 60, cfg.precisionBits ?? 48); + this.logger = logger.child({ component: "oracle" }); + } + + async update(): Promise { + const price = await this.instrument.getIndexPrice(); + this.currentPrice = price; + if (price > 0n) { + this.priceWindow.push(price); + this.volatility = this.priceWindow.volatility(); + } + this.logger.debug( + { price: price.toString(), volatility: this.volatility.valueOf() }, + "oracle tick", + ); + } +} diff --git a/market-maker/src/orderExecutor.ts b/market-maker/src/orderExecutor.ts new file mode 100644 index 0000000..6cf7d42 --- /dev/null +++ b/market-maker/src/orderExecutor.ts @@ -0,0 +1,250 @@ +import type pino from "pino"; +import type { + DesiredQuotes, + InstrumentAdapter, + OwnOrder, + QuoteLevel, +} from "./adapter.ts"; +import type { Quoter } from "./quoter.ts"; +import type { BookTracker } from "./bookTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import { bigAbs } from "./math.ts"; + +export interface OrderExecutorConfig { + /** Skip a requote if elapsed since last < cooldown (ms). */ + requoteCooldownMs: number; + /** Skip if price drifted < N ticks from last quote mid. */ + requoteThresholdTicks: number; + /** Override threshold (in ticks) when gas is spiking — quote anyway if drift >= this. */ + urgentRequoteThresholdTicks: number; + dryRun: boolean; +} + +/** + * Diff desired quotes vs the resting book; cancel + place via venue multicall. + * + * Cooldown, threshold, gas-spike deferral, partial-fill top-up are all here. + * Tx gas cost is reported back to RiskManager for budget enforcement. + */ +export class OrderExecutor { + readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; + + private lastRequoteAt = 0; + private lastQuoteMidPrice = 0n; + + private readonly instrument: InstrumentAdapter; + private readonly cfg: OrderExecutorConfig; + private readonly quoter: Quoter; + private readonly book: BookTracker; + private readonly gas: GasTracker; + private readonly risk: RiskManager; + private readonly oracle: OracleTracker; + private readonly logger: pino.Logger; + + constructor( + instrument: InstrumentAdapter, + cfg: OrderExecutorConfig, + quoter: Quoter, + book: BookTracker, + gas: GasTracker, + risk: RiskManager, + oracle: OracleTracker, + logger: pino.Logger, + ) { + this.instrument = instrument; + this.cfg = cfg; + this.quoter = quoter; + this.book = book; + this.gas = gas; + this.risk = risk; + this.oracle = oracle; + this.logger = logger.child({ component: "executor", instrument: instrument.id }); + } + + async reconcile(desired: DesiredQuotes): Promise { + if (!this.shouldRequote(desired)) { + this.logger.debug("requote skipped (within threshold or cooldown)"); + return; + } + + if (this.gas.isGasSpiking) { + const drift = this.priceDriftTicks(); + if (drift < this.cfg.urgentRequoteThresholdTicks) { + this.logger.info( + { + drift, + threshold: this.cfg.urgentRequoteThresholdTicks, + gasSpike: this.gas.gasSpikePct.toString(), + }, + "requote skipped: gas spike, drift below urgent threshold", + ); + return; + } + this.logger.warn({ drift }, "proceeding with requote despite gas spike"); + } + + const ordersToCancel = this.findStaleOrders(desired); + const ordersToPlace = this.findNewOrders(desired); + + if (ordersToCancel.length === 0 && ordersToPlace.length === 0) { + this.logger.debug("no order changes needed"); + return; + } + + const calls: `0x${string}`[] = []; + for (const order of ordersToCancel) { + calls.push(this.instrument.buildCancelCalldata(order.orderId)); + } + for (const level of ordersToPlace) { + calls.push(this.instrument.buildCreateCalldata(level.price, level.quantity)); + } + + if (this.cfg.dryRun) { + this.logger.info( + { cancels: ordersToCancel.length, places: ordersToPlace.length }, + "DRY RUN: would send multicall batch", + ); + return; + } + + const maxFeePerGas = this.gas.cappedGasPrice(); + try { + const hash = await this.instrument.venue.multicall(calls, { maxFeePerGas }); + const receipt = await this.instrument.venue.publicClient.waitForTransactionReceipt({ hash }); + const gasCost = this.computeTxGasCost(receipt); + this.risk.recordGasCost(gasCost); + + this.stats.ordersCancelled += ordersToCancel.length; + this.stats.ordersPlaced += ordersToPlace.length; + + this.logger.info( + { + cancels: ordersToCancel.length, + places: ordersToPlace.length, + gas: receipt.gasUsed.toString(), + }, + "multicall batch executed", + ); + } catch (err) { + this.logger.error( + { cancels: ordersToCancel.length, places: ordersToPlace.length, err }, + "multicall batch failed", + ); + throw err; + } + + this.lastRequoteAt = Date.now(); + this.lastQuoteMidPrice = this.oracle.currentPrice; + this.stats.reconcileCount++; + } + + async cancelAll(): Promise { + const orders = [...this.book.ownOrders.values()]; + if (orders.length === 0) return; + + this.logger.warn({ count: orders.length }, "cancelling all orders"); + const calls = orders.map((o) => this.instrument.buildCancelCalldata(o.orderId)); + + if (this.cfg.dryRun) { + this.logger.info({ count: orders.length }, "DRY RUN: would cancel all orders"); + return; + } + + const maxFeePerGas = this.gas.cappedGasPrice(); + try { + const hash = await this.instrument.venue.multicall(calls, { maxFeePerGas }); + const receipt = await this.instrument.venue.publicClient.waitForTransactionReceipt({ hash }); + const gasCost = this.computeTxGasCost(receipt); + this.risk.recordGasCost(gasCost); + this.stats.ordersCancelled += orders.length; + this.logger.info( + { count: orders.length, gas: receipt.gasUsed.toString() }, + "all orders cancelled", + ); + } catch (err) { + this.logger.error({ count: orders.length, err }, "cancel-all multicall failed"); + throw err; + } + } + + private shouldRequote(desired: DesiredQuotes): boolean { + if (Date.now() - this.lastRequoteAt < this.effectiveCooldownMs()) return false; + + const expectedCount = desired.bids.length + desired.asks.length; + if (this.book.ownOrders.size < expectedCount) return true; + if (this.hasQuantityDeficit(desired)) return true; + + return this.priceDriftTicks() >= this.effectiveRequoteThreshold(); + } + + private priceDriftTicks(): number { + if (this.lastQuoteMidPrice === 0n) return Number.POSITIVE_INFINITY; + const tick = this.quoter.getTick(); + if (tick === 0n) return 0; + const diff = bigAbs(this.oracle.currentPrice - this.lastQuoteMidPrice); + return Number(diff / tick); + } + + private effectiveCooldownMs(): number { + return this.risk.throttled ? this.cfg.requoteCooldownMs * 3 : this.cfg.requoteCooldownMs; + } + + private effectiveRequoteThreshold(): number { + return this.risk.throttled ? this.cfg.requoteThresholdTicks * 2 : this.cfg.requoteThresholdTicks; + } + + private findStaleOrders(desired: DesiredQuotes): OwnOrder[] { + const desiredPrices = new Set(); + for (const b of desired.bids) desiredPrices.add(b.price); + for (const a of desired.asks) desiredPrices.add(a.price); + const stale: OwnOrder[] = []; + for (const order of this.book.ownOrders.values()) { + if (!desiredPrices.has(order.price)) stale.push(order); + } + return stale; + } + + private findNewOrders(desired: DesiredQuotes): QuoteLevel[] { + const existing = this.aggregateOwnQuantityByPrice(); + const out: QuoteLevel[] = []; + for (const b of desired.bids) { + const have = existing.get(b.price) ?? 0n; + const deficit = b.quantity - have; + if (deficit > 0n) out.push({ price: b.price, quantity: deficit }); + } + for (const a of desired.asks) { + const have = existing.get(a.price) ?? 0n; + const deficit = a.quantity - have; + if (deficit < 0n) out.push({ price: a.price, quantity: deficit }); + } + return out; + } + + private hasQuantityDeficit(desired: DesiredQuotes): boolean { + const existing = this.aggregateOwnQuantityByPrice(); + for (const b of desired.bids) { + const have = existing.get(b.price); + if (have !== undefined && b.quantity - have > 0n) return true; + } + for (const a of desired.asks) { + const have = existing.get(a.price); + if (have !== undefined && a.quantity - have < 0n) return true; + } + return false; + } + + private aggregateOwnQuantityByPrice(): Map { + const m = new Map(); + for (const o of this.book.ownOrders.values()) { + m.set(o.price, (m.get(o.price) ?? 0n) + o.quantity); + } + return m; + } + + private computeTxGasCost(receipt: { gasUsed: bigint; effectiveGasPrice: bigint }): bigint { + if (this.gas.ethPriceUsd === 0n) return 0n; + return (receipt.gasUsed * receipt.effectiveGasPrice * this.gas.ethPriceUsd) / 10n ** 18n; + } +} diff --git a/market-maker/src/pricing/effectiveSpread.ts b/market-maker/src/pricing/effectiveSpread.ts new file mode 100644 index 0000000..aebb885 --- /dev/null +++ b/market-maker/src/pricing/effectiveSpread.ts @@ -0,0 +1,114 @@ +import Fraction from "fraction.js"; +import type { OracleTracker } from "../oracleTracker.ts"; +import type { GasTracker } from "../gasTracker.ts"; +import type { InventoryManager } from "../inventoryManager.ts"; +import { BPS_SCALE, calculateNotional } from "../math.ts"; +import { toBigint } from "../rational.ts"; + +export interface EffectiveSpreadConfig { + /** Floor spread in basis points; one-side half-spread is half this. */ + minSpreadBps: number; + /** Multiplier on realised volatility (Fraction → bps). */ + volatilityMultiplier: number; + /** Multiplier on |inventory skew| (×minSpreadBps). */ + inventorySkewGamma: number; + /** Penalty added when gas spikes (×spike fraction). */ + gasPenaltyBps: number; +} + +export interface MidQuote { + /** Bid mid (oracle − halfSpread − skewOffset). */ + bidMid: bigint; + /** Ask mid (oracle + halfSpread − skewOffset). */ + askMid: bigint; + /** Effective full spread used (Fraction bps, for diagnostics). */ + spreadBps: Fraction; +} + +/** + * Effective-spread mid pricing, ported from the legacy perps quoter. + * + * Spread = max(minSpread, gasFloor) + volMult·σ·1e4 + γ·|skew|·minSpread + gasPenalty·spike% + * Skew offset shifts both bid and ask down (long inv) or up (short inv). + */ +export function computeMidQuote(opts: { + oracle: OracleTracker; + gas: GasTracker; + inventory: InventoryManager; + cfg: EffectiveSpreadConfig; + baseQuantity: bigint; + maxSkewTicks: number; + tick: bigint; +}): MidQuote { + const { oracle, gas, inventory, cfg, baseQuantity, maxSkewTicks, tick } = opts; + const oraclePrice = oracle.currentPrice; + + const spreadBps = effectiveSpreadBps({ oracle, gas, inventory, cfg, baseQuantity }); + const halfSpreadBps = spreadBps.div(new Fraction(2n)); + + const skewOffset = inventorySkewOffset({ inventory, oraclePrice, maxSkewTicks, tick, gamma: cfg.inventorySkewGamma }); + + const halfBpsBig = bpsToBigint(halfSpreadBps); + const bidMid = (oraclePrice * (BPS_SCALE - halfBpsBig)) / BPS_SCALE - skewOffset; + const askMid = (oraclePrice * (BPS_SCALE + halfBpsBig)) / BPS_SCALE - skewOffset; + + return { bidMid, askMid, spreadBps }; +} + +function effectiveSpreadBps(opts: { + oracle: OracleTracker; + gas: GasTracker; + inventory: InventoryManager; + cfg: EffectiveSpreadConfig; + baseQuantity: bigint; +}): Fraction { + const { oracle, gas, inventory, cfg, baseQuantity } = opts; + + const gasFloor = gasFloorBps(oracle, gas, baseQuantity); + const minSpread = new Fraction(cfg.minSpreadBps); + const base = gasFloor.compare(minSpread) > 0 ? gasFloor : minSpread; + + // vol Fraction (stddev of log returns) * multiplier * 10000 gives bps + const vol = oracle.volatility.mul(new Fraction(Math.round(cfg.volatilityMultiplier * 1_000_000), 1_000_000)).mul( + new Fraction(10_000n), + ); + + const skewAbs = inventory.inventorySkew.abs(); + const inv = skewAbs.mul(minSpread); + + const spike = gas.gasSpikePct; + const gasPenalty = spike.compare(new Fraction(0n)) > 0 + ? spike.div(new Fraction(100n)).mul(new Fraction(cfg.gasPenaltyBps)) + : new Fraction(0n); + + return base.add(vol).add(inv).add(gasPenalty); +} + +function gasFloorBps(oracle: OracleTracker, gas: GasTracker, baseQuantity: bigint): Fraction { + const rt = gas.roundTripCostUsd; + if (rt === 0n) return new Fraction(0n); + const expectedNotional = calculateNotional(oracle.currentPrice, baseQuantity); + if (expectedNotional === 0n) return new Fraction(0n); + return new Fraction(rt * 10_000n, expectedNotional); +} + +function inventorySkewOffset(opts: { + inventory: InventoryManager; + oraclePrice: bigint; + maxSkewTicks: number; + tick: bigint; + gamma: number; +}): bigint { + const { inventory, oraclePrice, maxSkewTicks, tick, gamma } = opts; + if (oraclePrice === 0n || tick === 0n) return 0n; + // skewTicks = round(gamma * skew * maxSkewTicks) + const skewTicks = inventory.inventorySkew + .mul(new Fraction(Math.round(gamma * 1_000_000), 1_000_000)) + .mul(new Fraction(maxSkewTicks)); + const skewTicksBig = toBigint(skewTicks, 1n, "nearest"); + return skewTicksBig * tick; +} + +function bpsToBigint(bpsFraction: Fraction): bigint { + return toBigint(bpsFraction, 1n, "nearest"); +} diff --git a/market-maker/src/pricing/reservationPrice.ts b/market-maker/src/pricing/reservationPrice.ts new file mode 100644 index 0000000..be26805 --- /dev/null +++ b/market-maker/src/pricing/reservationPrice.ts @@ -0,0 +1,111 @@ +import Fraction from "fraction.js"; +import { fromNumber, fromRatio, toBigint } from "../rational.ts"; +import { BPS_SCALE, QUANTITY_SCALE, roundDownToTick, roundUpToTick } from "../math.ts"; +import type { OracleTracker } from "../oracleTracker.ts"; +import type { GasTracker } from "../gasTracker.ts"; +import type { InventoryManager } from "../inventoryManager.ts"; +import type { InstrumentContext } from "../adapter.ts"; +import type { MidQuote } from "./effectiveSpread.ts"; + +export interface ReservationPriceConfig { + /** Avellaneda–Stoikov risk aversion γ. Positive → short inventory pushes mid up, + * long inventory pushes mid down. Tuned relative to the σ × T product in your + * deployment: if σ is per-poll (~3 s) and T is in seconds, γ should be chosen + * to produce a sensible tick-level shift at max inventory. */ + riskAversion: number; + /** Fallback remaining-time value (seconds) used when InstrumentContext has no + * deliveryDate (e.g. during testing or for non-delivery instruments). */ + marginCallTimeSeconds: number; + /** Floor half-spread in basis points. */ + minSpreadBps: number; + /** Widens spread by this multiple of σ (per-poll vol × 10 000 bps). */ + volatilityMultiplier: number; + /** Penalty added to spread when gas price spikes. */ + gasPenaltyBps: number; +} + +/** + * Avellaneda–Stoikov reservation-price mid-quote. + * + * r = S − q · γ · σ² · T + * + * S = oracle index price + * q = signed inventory in contracts (netQuantity / QUANTITY_SCALE) + * γ = risk aversion coefficient (ReservationPriceConfig.riskAversion) + * σ = realized volatility per poll step (OracleTracker.volatility, a Fraction) + * T = remaining time in seconds until delivery / margin-call + * + * All arithmetic on Fraction until the final tick-quantisation step. + * The spread around r is computed identically to EffectiveSpread + * (vol-widened, gas-floored), so the two strategies are drop-in-swappable + * inside Quoter. + */ +export function computeReservationMidQuote(opts: { + oracle: OracleTracker; + gas: GasTracker; + inventory: InventoryManager; + context: InstrumentContext; + cfg: ReservationPriceConfig; + tick: bigint; + nowMs?: number; +}): MidQuote { + const { oracle, gas, inventory, context, cfg, tick, nowMs = Date.now() } = opts; + const S = oracle.currentPrice; + + // ── Reservation price ──────────────────────────────────────────────────── + const sigma = oracle.volatility; // Fraction, per-poll + const sigma2 = sigma.mul(sigma); + const gamma = fromNumber(cfg.riskAversion); + + const remainingSeconds: Fraction = context.deliveryDate !== undefined + ? fromNumber(Math.max(0, context.deliveryDate - nowMs / 1000)) + : fromNumber(cfg.marginCallTimeSeconds); + + // q = netQuantity / QUANTITY_SCALE (signed, in "contracts") + const q = new Fraction(inventory.netQuantity, QUANTITY_SCALE); + + // r = S - q·γ·σ²·T + const adjustment = q.mul(gamma).mul(sigma2).mul(remainingSeconds); + const rFrac = fromRatio(S).sub(adjustment); + const rBigint = toBigint(rFrac, 1n, "nearest"); + const r = rBigint > tick ? rBigint : tick; // floor at 1 tick + + // ── Spread ─────────────────────────────────────────────────────────────── + const spreadBps = halfSpreadBps({ oracle, gas, cfg }).mul(new Fraction(2n)); + const halfBps = halfSpreadBps({ oracle, gas, cfg }); + const halfBpsBig = toBigint(halfBps, 1n, "nearest"); + + const bidRaw = (r * (BPS_SCALE - halfBpsBig)) / BPS_SCALE; + const askRaw = (r * (BPS_SCALE + halfBpsBig)) / BPS_SCALE; + + const bidMid = roundDownToTick(bidRaw > tick ? bidRaw : tick, tick); + const askMid = roundUpToTick(askRaw > tick ? askRaw : tick, tick); + + return { bidMid, askMid, spreadBps }; +} + +function halfSpreadBps(opts: { + oracle: OracleTracker; + gas: GasTracker; + cfg: ReservationPriceConfig; +}): Fraction { + const { oracle, gas, cfg } = opts; + + const minSpread = fromNumber(cfg.minSpreadBps / 2); // half-spread floor + + // vol component: σ * volatilityMultiplier * 10000 bps, halved for half-spread + const volBps = oracle.volatility + .mul(fromNumber(cfg.volatilityMultiplier)) + .mul(new Fraction(10_000n)) + .div(new Fraction(2n)); + + const base = volBps.compare(minSpread) > 0 ? volBps : minSpread; + + // gas spike penalty + const spike = gas.gasSpikePct; + const gasPenalty = spike.compare(new Fraction(0n)) > 0 + ? spike.div(new Fraction(100n)).mul(fromNumber(cfg.gasPenaltyBps / 2)) + : new Fraction(0n); + + return base.add(gasPenalty); +} diff --git a/market-maker/src/quoter.ts b/market-maker/src/quoter.ts new file mode 100644 index 0000000..0d16912 --- /dev/null +++ b/market-maker/src/quoter.ts @@ -0,0 +1,153 @@ +import type pino from "pino"; +import type Fraction from "fraction.js"; +import type { DesiredQuotes, InstrumentAdapter, InstrumentContext, QuoteLevel } from "./adapter.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { RiskManager } from "./riskManager.ts"; +import { roundDownToTick, roundUpToTick } from "./math.ts"; +import { computeMidQuote, type EffectiveSpreadConfig } from "./pricing/effectiveSpread.ts"; +import { computeReservationMidQuote, type ReservationPriceConfig } from "./pricing/reservationPrice.ts"; +import { linearSizes } from "./sizing/linear.ts"; +import { geometricTaperSizes } from "./sizing/geometricTaper.ts"; + +export type { ReservationPriceConfig }; +export type PricingStrategyName = "effective-spread" | "reservation-price"; +export type SizingStrategyName = "linear" | "geometric-taper"; + +export interface QuoterConfig { + pricing: + | ({ strategy: "effective-spread" } & EffectiveSpreadConfig) + | ({ strategy: "reservation-price" } & ReservationPriceConfig); + sizing: + | { strategy: "linear"; baseQuantity: bigint; numLevelsPerSide: number } + | { strategy: "geometric-taper"; baseQuantity: bigint; numLevelsPerSide: number; taperRatio: number }; + /** Max ticks the inventory skew can shift quotes (effective-spread only). */ + maxSkewTicks: number; +} + +/** + * Computes desired bid/ask quotes for one instrument by combining a pricing strategy + * (mid + spread) with a sizing strategy (per-level quantities). + * + * Stateless across ticks; all state lives in the trackers it reads from. + */ +export class Quoter { + private tick = 0n; + private context: InstrumentContext = {}; + private readonly instrument: InstrumentAdapter; + private readonly cfg: QuoterConfig; + private readonly oracle: OracleTracker; + private readonly gas: GasTracker; + private readonly inventory: InventoryManager; + private readonly risk: RiskManager; + private readonly logger: pino.Logger; + + constructor( + instrument: InstrumentAdapter, + cfg: QuoterConfig, + oracle: OracleTracker, + gas: GasTracker, + inventory: InventoryManager, + risk: RiskManager, + logger: pino.Logger, + ) { + this.instrument = instrument; + this.cfg = cfg; + this.oracle = oracle; + this.gas = gas; + this.inventory = inventory; + this.risk = risk; + this.logger = logger.child({ component: "quoter", instrument: instrument.id }); + } + + async initialize(): Promise { + this.tick = await this.instrument.getMinTick(); + this.context = await this.instrument.getContext(); + this.logger.info( + { tick: this.tick.toString(), deliveryDate: this.context.deliveryDate }, + "quoter initialized", + ); + } + + getTick(): bigint { + return this.tick; + } + + computeQuotes(): DesiredQuotes { + const oraclePrice = this.oracle.currentPrice; + if (oraclePrice === 0n || this.tick === 0n) { + return { bids: [], asks: [] }; + } + + const sizes = this.computeSizes(); + const midQuote = this.cfg.pricing.strategy === "reservation-price" + ? computeReservationMidQuote({ + oracle: this.oracle, + gas: this.gas, + inventory: this.inventory, + context: this.context, + cfg: this.cfg.pricing, + tick: this.tick, + }) + : computeMidQuote({ + oracle: this.oracle, + gas: this.gas, + inventory: this.inventory, + cfg: this.cfg.pricing, + baseQuantity: this.cfg.sizing.baseQuantity, + maxSkewTicks: this.cfg.maxSkewTicks, + tick: this.tick, + }); + + const { bidMid, askMid, spreadBps } = midQuote; + const { quoteBid, quoteAsk } = this.risk.allowedSides(); + + const bids: QuoteLevel[] = []; + const asks: QuoteLevel[] = []; + + for (let level = 0; level < sizes.length; level++) { + const levelTicks = BigInt(level) * this.tick; + const qty = sizes[level]; + + if (quoteBid) { + const bidRaw = bidMid - levelTicks; + const bidPrice = roundDownToTick(bidRaw > 0n ? bidRaw : this.tick, this.tick); + bids.push({ price: bidPrice, quantity: qty }); + } + + if (quoteAsk) { + const askRaw = askMid + levelTicks; + const askPrice = roundUpToTick(askRaw, this.tick); + if (askPrice > 0n) asks.push({ price: askPrice, quantity: -qty }); + } + } + + this.logger.debug( + { + strategy: this.cfg.pricing.strategy, + spreadBps: fractionToString(spreadBps), + bidLevels: bids.length, + askLevels: asks.length, + bidTop: bids[0]?.price.toString(), + askTop: asks[0]?.price.toString(), + }, + "quotes computed", + ); + + return { bids, asks }; + } + + private computeSizes(): bigint[] { + const s = this.cfg.sizing; + if (s.strategy === "linear") { + return linearSizes(s.baseQuantity, s.numLevelsPerSide); + } + return geometricTaperSizes(s.baseQuantity * BigInt(s.numLevelsPerSide), s.taperRatio, s.numLevelsPerSide); + } +} + +function fractionToString(f: Fraction): string { + // safe approximation for diagnostics; never used in trading math + return (Number(f.n) / Number(f.d)).toFixed(2); +} diff --git a/market-maker/src/rational.ts b/market-maker/src/rational.ts new file mode 100644 index 0000000..04961f2 --- /dev/null +++ b/market-maker/src/rational.ts @@ -0,0 +1,190 @@ +import Fraction from "fraction.js"; + +/** + * Approximations for irrational results on `fraction.js` Fractions. + * + * `Fraction` itself is exact rational arithmetic with BigInt internals. + * `sqrt` and `ln` are irrational in general; this module gives bounded-precision + * Fraction approximations using bigint-only math. No `Number` is used in the hot path. + * + * Precision is expressed in fractional bits: a value of `precisionBits = b` returns + * a Fraction with denominator <= 2^b that approximates the true value within roughly + * 2^-b relative error. + */ + +/** Floor of integer square root of a non-negative bigint (Newton's method). */ +export function bigintSqrtFloor(n: bigint): bigint { + if (n < 0n) throw new RangeError("bigintSqrtFloor: negative"); + if (n < 2n) return n; + let x = n; + let y = (x + 1n) >> 1n; + while (y < x) { + x = y; + y = (x + n / x) >> 1n; + } + return x; +} + +/** Absolute value of a bigint. */ +function babs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** + * Square root of a non-negative Fraction with `precisionBits` fractional bits. + * + * Returned Fraction = floor(sqrt(x * 4^b)) / 2^b, where b = precisionBits. + * The relative error is at most 2 * 2^-b for x >= 1. + */ +export function sqrt(x: Fraction, precisionBits = 64): Fraction { + if (x.s < 0) throw new RangeError("sqrt: negative"); + if (x.n === 0n) return new Fraction(0n); + + // x = n/d => sqrt(x) ≈ floor(sqrt(n * 4^b * d)) / (2^b * d) + // We compute as bigint to avoid any Number conversion. + const b = BigInt(precisionBits); + const scale = 1n << b; // 2^b + const scaleSquared = scale * scale; // 4^b + + // floor(sqrt(n * 4^b / d)) is what we want; multiply by d to get exact integer + // sqrt(n/d) * 2^b = sqrt(n * 4^b / d) = sqrt(n * 4^b * d) / d + const radicand = x.n * scaleSquared * x.d; + const root = bigintSqrtFloor(radicand); + // Sign of x is non-negative; n/d in fraction.js stores absolute values with .s + return new Fraction(root, scale * x.d); +} + +/** + * Natural log of a positive Fraction with `precisionBits` fractional bits of accuracy. + * + * Strategy: + * 1. Reduce x to y in [1/2, 2] by dividing by 2^k (k can be negative). + * ln(x) = k * ln(2) + ln(y) + * 2. For y in [1/2, 2], let u = (y - 1) / (y + 1), |u| <= 1/3. + * ln(y) = 2 * (u + u^3/3 + u^5/5 + ...) + * Series converges geometrically; truncate when terms drop below precision. + * 3. ln(2) is computed once at the requested precision via the same atanh series + * applied to (3-1)/(3+1) and the identity ln(2) = ln(4/3) + ln(3/2), + * but it's simpler to use ln(2) = -ln(1/2) computed by the same series with y=1/2. + */ +export function ln(x: Fraction, precisionBits = 64): Fraction { + if (x.s <= 0 || x.n === 0n) { + throw new RangeError("ln: argument must be positive"); + } + + // Step 1: reduce to y in [1/2, 2] by extracting powers of 2. + // We compare numerator vs denominator * 2^k. + let { n, d } = x; + let k = 0n; + // Halve while x >= 2 <=> n >= 2*d + while (n >= 2n * d) { + d *= 2n; + k += 1n; + } + // Double while x < 1/2 <=> n*2 < d <=> d > 2*n + while (d > 2n * n) { + n *= 2n; + k -= 1n; + } + const y = new Fraction(n, d); // y in [1/2, 2] + + const lnY = atanhSeries(y, precisionBits); + if (k === 0n) return lnY; + + const ln2 = ln2Cached(precisionBits); + return lnY.add(ln2.mul(new Fraction(k))); +} + +/** + * Computes ln(y) for y in [1/2, 2] using the atanh series: + * ln(y) = 2 * sum_{i=0..inf} u^(2i+1) / (2i+1), where u = (y-1)/(y+1) + * + * |u| <= 1/3 in this interval, so the series converges quickly. + * Truncates when the next term drops below 2^-precisionBits relative to result. + */ +function atanhSeries(y: Fraction, precisionBits: number): Fraction { + const u = y.sub(1).div(y.add(1)); // (y-1)/(y+1) + if (u.n === 0n) return new Fraction(0n); + + const u2 = u.mul(u); + let term = u; // u^1 / 1 + let sum = term; + // tolerance: when |term| < 2^-precisionBits we stop + const tolDen = 1n << BigInt(precisionBits); + // term threshold as Fraction: 1/2^precisionBits + const tolerance = new Fraction(1n, tolDen); + + let i = 1n; + while (true) { + // next term = previous * u^2 * (2i-1)/(2i+1) + const idx2 = 2n * i + 1n; + term = term.mul(u2).mul(new Fraction(2n * i - 1n, idx2)); + sum = sum.add(term); + if (term.abs().compare(tolerance) < 0) break; + i += 1n; + if (i > 10000n) break; // safety + } + return sum.mul(new Fraction(2n)); +} + +const ln2Cache = new Map(); +function ln2Cached(precisionBits: number): Fraction { + const cached = ln2Cache.get(precisionBits); + if (cached) return cached; + // ln(2) = -ln(1/2). y = 1/2 reduces to itself with k=0, so atanhSeries handles it. + const v = atanhSeries(new Fraction(1n, 2n), precisionBits).neg(); + ln2Cache.set(precisionBits, v); + return v; +} + +/** + * Convert a Fraction to bigint at a given scale, using nearest-even rounding. + * E.g. toBigint(Fraction(7, 3), 1n) ~ 2n (since 7/3 = 2.33...). + * + * `scale` is the integer denominator the result will be expressed against, + * i.e. result represents `value * scale` rounded to the nearest integer. + */ +export function toBigint(value: Fraction, scale: bigint = 1n, mode: "nearest" | "floor" | "ceil" = "nearest"): bigint { + if (scale <= 0n) throw new RangeError("toBigint: scale must be positive"); + const num = value.n * scale * BigInt(value.s); + const den = value.d; + if (mode === "floor") { + return floorDiv(num, den); + } + if (mode === "ceil") { + return -floorDiv(-num, den); + } + // nearest-even (banker's rounding) + const q = floorDiv(num, den); + const r = num - q * den; // 0 <= r < den + const twice = 2n * r; + if (twice < den) return q; + if (twice > den) return q + 1n; + // exactly half — pick even + return (q & 1n) === 0n ? q : q + 1n; +} + +/** Floor division for bigints (Math.floor semantics, including negatives). */ +export function floorDiv(a: bigint, b: bigint): bigint { + if (b < 0n) { + a = -a; + b = -b; + } + const q = a / b; + const r = a - q * b; + if (r < 0n) return q - 1n; + return q; +} + +/** Construct a Fraction from a bigint ratio safely. */ +export function fromRatio(num: bigint, den: bigint = 1n): Fraction { + return new Fraction(num, den); +} + +/** Construct a Fraction approximating a JavaScript number (use only at IO boundaries). */ +export function fromNumber(value: number): Fraction { + return new Fraction(value); +} + +/** Re-export Fraction for convenience so callers don't need a separate import. */ +export { default as Fraction } from "fraction.js"; diff --git a/market-maker/src/registry.ts b/market-maker/src/registry.ts new file mode 100644 index 0000000..e69eed0 --- /dev/null +++ b/market-maker/src/registry.ts @@ -0,0 +1,38 @@ +import type pino from "pino"; +import { ConfigError } from "./errors.ts"; +import type { VenueAdapter, VenueKind } from "./adapter.ts"; +import type { MakerConfig } from "./config.ts"; +import type { WalletRegistry } from "./wallet.ts"; +import type { NetworkClients } from "./client.ts"; + +export interface AdapterFactoryContext { + config: MakerConfig; + wallets: WalletRegistry; + network: NetworkClients; + logger: pino.Logger; +} + +export type AdapterFactory = (ctx: AdapterFactoryContext) => Promise; + +const factories = new Map(); + +export function registerAdapter(kind: VenueKind, factory: AdapterFactory): void { + factories.set(kind, factory); +} + +export async function createAdapter( + kind: VenueKind, + ctx: AdapterFactoryContext, +): Promise { + const factory = factories.get(kind); + if (!factory) { + const known = [...factories.keys()].join(", ") || ""; + throw new ConfigError(`No adapter registered for kind "${kind}". Registered: ${known}`); + } + return factory(ctx); +} + +/** Test helper: clear the registry. */ +export function _clearRegistry(): void { + factories.clear(); +} diff --git a/market-maker/src/riskManager.ts b/market-maker/src/riskManager.ts new file mode 100644 index 0000000..edb665b --- /dev/null +++ b/market-maker/src/riskManager.ts @@ -0,0 +1,166 @@ +import type pino from "pino"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import { RollingBudget, bigAbs } from "./math.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export type ThrottleReason = "gas_hourly" | "gas_daily" | "none"; + +export interface RiskManagerConfig { + maxPositionSize: bigint; + /** Stop quoting both sides when utilization exceeds this percentage. */ + maxUtilizationPct: number; + minCollateralBalance: bigint; + maxDailyLossUsd: bigint; + maxGasBudgetPerHourUsd: bigint; + maxGasBudgetPerDayUsd: bigint; +} + +/** + * Risk halts (stop quoting and cancel) and throttles (slow down quoting). + * + * Halts are recoverable on the next tick once the underlying condition clears. + * Daily PnL counters reset at midnight UTC. + */ +export class RiskManager { + halted = false; + haltReason: ErrorInfo | null = null; + throttled = false; + throttleReason: ThrottleReason = "none"; + + cumulativeGasCostUsd = 0n; + + private readonly gasHourlyBudget: RollingBudget; + private readonly gasDailyBudget: RollingBudget; + + private startOfDayBalance = 0n; + private startOfDayTimestamp = 0; + + private readonly cfg: RiskManagerConfig; + private readonly inventory: InventoryManager; + private readonly gas: GasTracker; + private readonly oracle: OracleTracker; + private readonly logger: pino.Logger; + + constructor( + cfg: RiskManagerConfig, + inventory: InventoryManager, + gas: GasTracker, + oracle: OracleTracker, + logger: pino.Logger, + ) { + this.cfg = cfg; + this.inventory = inventory; + this.gas = gas; + this.oracle = oracle; + this.logger = logger.child({ component: "risk" }); + this.gasHourlyBudget = new RollingBudget(60 * 60 * 1000); + this.gasDailyBudget = new RollingBudget(24 * 60 * 60 * 1000); + } + + /** Snapshot starting collateral; call once after first inventory update. */ + initialize(): void { + this.startOfDayBalance = this.inventory.collateralBalance; + this.startOfDayTimestamp = Date.now(); + } + + recordGasCost(costUsd: bigint): void { + this.gasHourlyBudget.add(costUsd); + this.gasDailyBudget.add(costUsd); + this.cumulativeGasCostUsd += costUsd; + } + + /** Returns true if the bot should continue quoting. */ + check(): boolean { + this.checkDayRollover(); + + if (this.inventory.collateralBalance < this.cfg.minCollateralBalance) { + this.halted = true; + this.haltReason = { + message: "collateral below minimum", + balance: this.inventory.collateralBalance.toString(), + min: this.cfg.minCollateralBalance.toString(), + }; + this.logger.error(this.haltReason, "HALT: collateral below minimum"); + return false; + } + + const truePnl = this.truePnl(); + if (truePnl < 0n && bigAbs(truePnl) > this.cfg.maxDailyLossUsd) { + this.halted = true; + this.haltReason = { + message: "daily loss limit breached", + pnl: truePnl.toString(), + max: this.cfg.maxDailyLossUsd.toString(), + }; + this.logger.error(this.haltReason, "HALT: daily loss limit breached"); + return false; + } + + this.halted = false; + this.haltReason = null; + + const hourlyGas = this.gasHourlyBudget.total(); + if (hourlyGas > this.cfg.maxGasBudgetPerHourUsd) { + this.throttled = true; + this.throttleReason = "gas_hourly"; + this.logger.warn( + { hourlyGas: hourlyGas.toString(), max: this.cfg.maxGasBudgetPerHourUsd.toString() }, + "throttled: hourly gas budget exceeded", + ); + } else { + const dailyGas = this.gasDailyBudget.total(); + if (dailyGas > this.cfg.maxGasBudgetPerDayUsd) { + this.throttled = true; + this.throttleReason = "gas_daily"; + this.logger.warn({ dailyGas: dailyGas.toString() }, "throttled: daily gas budget exceeded"); + } else { + this.throttled = false; + this.throttleReason = "none"; + } + } + + return true; + } + + /** + * Sides allowed to quote. Respects position cap and stops quoting at high utilization + * (only the side that reduces exposure is allowed). + */ + allowedSides(): { quoteBid: boolean; quoteAsk: boolean } { + const maxPos = this.cfg.maxPositionSize; + const net = this.inventory.netQuantity; + + if (this.inventory.utilizationPct > this.cfg.maxUtilizationPct) { + if (net > 0n) return { quoteBid: false, quoteAsk: true }; + if (net < 0n) return { quoteBid: true, quoteAsk: false }; + return { quoteBid: false, quoteAsk: false }; + } + + return { + quoteBid: net < maxPos, + quoteAsk: net > -maxPos, + }; + } + + /** Net PnL today including gas. Negative = loss. */ + private truePnl(): bigint { + const balanceDelta = this.inventory.collateralBalance - this.startOfDayBalance; + return balanceDelta - this.cumulativeGasCostUsd; + } + + private checkDayRollover(): void { + const now = Date.now(); + const todayMidnight = new Date(); + todayMidnight.setUTCHours(0, 0, 0, 0); + const midnightMs = todayMidnight.getTime(); + + if (this.startOfDayTimestamp < midnightMs && now >= midnightMs) { + this.startOfDayBalance = this.inventory.collateralBalance; + this.startOfDayTimestamp = now; + this.cumulativeGasCostUsd = 0n; + this.logger.info("day rollover: PnL counters reset"); + } + } +} diff --git a/market-maker/src/sizing/geometricTaper.ts b/market-maker/src/sizing/geometricTaper.ts new file mode 100644 index 0000000..06d80a1 --- /dev/null +++ b/market-maker/src/sizing/geometricTaper.ts @@ -0,0 +1,36 @@ +import Fraction from "fraction.js"; +import { toBigint } from "../rational.ts"; + +/** + * Geometric taper sizing: each successive level is `ratio` of the previous one. + * The total inventory across all levels equals `totalQuantity`. + * + * q_k = totalQuantity * ratio^k * (1 − ratio) / (1 − ratio^N) + * + * For ratio=0.5, sizes are { Q/2, Q/4, Q/8, ... }. For ratio→1, sizes flatten. + * + * Throws if ratio ∉ (0, 1) or numLevels < 1. Uses Fraction for exactness then + * floors to bigint per level. + */ +export function geometricTaperSizes(totalQuantity: bigint, ratio: number, numLevels: number): bigint[] { + if (numLevels < 1) throw new Error("numLevels must be >= 1"); + if (!(ratio > 0 && ratio < 1)) throw new Error("ratio must be in (0, 1)"); + const r = new Fraction(Math.round(ratio * 1_000_000), 1_000_000); + const one = new Fraction(1n); + // ratio^k + const powers: Fraction[] = []; + let p = one; + for (let k = 0; k < numLevels; k++) { + powers.push(p); + p = p.mul(r); + } + let denom = new Fraction(0n); + for (const x of powers) denom = denom.add(x); + const totalQ = new Fraction(totalQuantity); + const out: bigint[] = []; + for (const pk of powers) { + const qFrac = totalQ.mul(pk).div(denom); + out.push(toBigint(qFrac, 1n, "floor")); + } + return out; +} diff --git a/market-maker/src/sizing/linear.ts b/market-maker/src/sizing/linear.ts new file mode 100644 index 0000000..ec989be --- /dev/null +++ b/market-maker/src/sizing/linear.ts @@ -0,0 +1,11 @@ +/** + * Linear ladder sizing: level k gets (k+1) * baseQuantity. + * level 0 = base, level 1 = 2*base, level 2 = 3*base, ... + */ +export function linearSizes(baseQuantity: bigint, numLevels: number): bigint[] { + const out: bigint[] = []; + for (let k = 0; k < numLevels; k++) { + out.push(baseQuantity * BigInt(k + 1)); + } + return out; +} diff --git a/market-maker/src/wallet.ts b/market-maker/src/wallet.ts new file mode 100644 index 0000000..41d2764 --- /dev/null +++ b/market-maker/src/wallet.ts @@ -0,0 +1,46 @@ +import type { Account, Chain, Hex, Transport, WalletClient } from "viem"; +import { ConfigError } from "./errors.ts"; +import { createWalletFromKey } from "./client.ts"; + +export interface WalletContext { + name: string; + account: Account; + walletClient: WalletClient; +} + +/** + * Resolves named wallets declared in config into live viem wallet contexts. + * One `privateKeyToAccount` call per name — shared when multiple venues reference + * the same wallet name. + */ +export class WalletRegistry { + private readonly contexts = new Map(); + + constructor( + walletConfigs: Record, + chain: Chain, + transport: Transport, + ) { + for (const [name, cfg] of Object.entries(walletConfigs)) { + const { account, walletClient } = createWalletFromKey(cfg.privateKey, chain, transport); + this.contexts.set(name, { name, account, walletClient }); + } + } + + get(name: string): WalletContext { + const ctx = this.contexts.get(name); + if (!ctx) { + const known = [...this.contexts.keys()].join(", ") || ""; + throw new ConfigError(`Unknown wallet "${name}". Declared wallets: ${known}`); + } + return ctx; + } + + has(name: string): boolean { + return this.contexts.has(name); + } + + names(): string[] { + return [...this.contexts.keys()]; + } +} diff --git a/market-maker/tests-pending/bookTracker.test.ts b/market-maker/tests-pending/bookTracker.test.ts new file mode 100644 index 0000000..32583d1 --- /dev/null +++ b/market-maker/tests-pending/bookTracker.test.ts @@ -0,0 +1,305 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { BookTracker } from "../src/bookTracker.ts"; +import type { MakerConfig } from "../src/config.ts"; + +function makeConfig(): MakerConfig { + return { + perpsAddress: "0x0000000000000000000000000000000000000001", + resyncIntervalMs: 60000, + } as unknown as MakerConfig; +} + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +const MM_ADDRESS = "0x000000000000000000000000000000000000aaaa" as `0x${string}`; +const OTHER_ADDRESS = "0x000000000000000000000000000000000000bbbb" as `0x${string}`; + +function makeOrderId(n: number): `0x${string}` { + return `0x${n.toString(16).padStart(64, "0")}` as `0x${string}`; +} + +function makeEmptyClient() { + return { + readContract: async (args: { functionName: string }) => { + if (args.functionName === "getOrderBookPrices") return [[], []]; + if (args.functionName === "getUserOrders") return []; + return undefined; + }, + multicall: async () => [], + watchContractEvent: () => () => {}, + }; +} + +describe("BookTracker", () => { + it("starts with zero best bid/ask", () => { + const tracker = new BookTracker({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + assert.equal(tracker.bestBid, 0n); + assert.equal(tracker.bestAsk, 0n); + assert.equal(tracker.midPrice, 0n); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("depthAtPrice returns 0 for unknown prices", () => { + const tracker = new BookTracker({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + assert.equal(tracker.depthAtPrice(100_000_000n, true), 0n); + assert.equal(tracker.depthAtPrice(100_000_000n, false), 0n); + }); + + it("start performs full resync and watches events", async () => { + let watchCalled = false; + const client = { + ...makeEmptyClient(), + watchContractEvent: () => { + watchCalled = true; + return () => {}; + }, + }; + const tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + assert.ok(watchCalled); + }); + + it("processes events delivered via onLogs callback", async () => { + let capturedOnLogs: ((logs: unknown[]) => void) | null = null; + const client = { + ...makeEmptyClient(), + watchContractEvent: (opts: { onLogs: (logs: unknown[]) => void }) => { + capturedOnLogs = opts.onLogs; + return () => {}; + }, + }; + const tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + assert.ok(capturedOnLogs); + + const orderId = makeOrderId(99); + capturedOnLogs([ + { + eventName: "OrderCreated", + args: { participant: MM_ADDRESS, orderId, price: 100_000_000n, quantity: 5_000_000n }, + }, + ]); + assert.equal(tracker.ownOrders.size, 1); + assert.equal(tracker.ownOrders.get(orderId)?.price, 100_000_000n); + }); + + it("stop calls unwatch", async () => { + let unwatchCalled = false; + const client = { + ...makeEmptyClient(), + watchContractEvent: () => { + return () => { + unwatchCalled = true; + }; + }, + }; + const tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + tracker.stop(); + assert.ok(unwatchCalled); + }); + + it("stop is safe to call without start", () => { + const tracker = new BookTracker({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + tracker.stop(); + }); + + it("resync sets best bid/ask/mid from contract", async () => { + const bids = [110_000_000n, 100_000_000n]; + const asks = [120_000_000n, 130_000_000n]; + const client = { + readContract: async (args: { functionName: string }) => { + if (args.functionName === "getOrderBookPrices") return [bids, asks]; + if (args.functionName === "getUserOrders") return []; + return undefined; + }, + multicall: async (args: { contracts: unknown[] }) => { + return args.contracts.map(() => 5_000_000n); + }, + watchContractEvent: () => () => {}, + }; + const tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + + assert.equal(tracker.bestBid, 110_000_000n); + assert.equal(tracker.bestAsk, 120_000_000n); + assert.equal(tracker.midPrice, 115_000_000n); + assert.equal(tracker.depthAtPrice(110_000_000n, true), 5_000_000n); + }); + + it("resync loads own orders", async () => { + const orderId1 = makeOrderId(1); + const client = { + readContract: async (args: { functionName: string }) => { + if (args.functionName === "getOrderBookPrices") return [[], []]; + if (args.functionName === "getUserOrders") return [orderId1]; + return undefined; + }, + multicall: async () => [ + { participant: MM_ADDRESS, price: 100_000_000n, quantity: 5_000_000n }, + ], + watchContractEvent: () => () => {}, + }; + const tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + + assert.equal(tracker.ownOrders.size, 1); + assert.equal(tracker.ownOrders.get(orderId1)?.price, 100_000_000n); + }); + + it("refresh triggers resync when interval elapsed", async () => { + let resyncCount = 0; + const client = { + readContract: async (args: { functionName: string }) => { + if (args.functionName === "getOrderBookPrices") { + resyncCount++; + return [[], []]; + } + if (args.functionName === "getUserOrders") return []; + return undefined; + }, + multicall: async () => [], + watchContractEvent: () => () => {}, + }; + const config = { ...makeConfig(), resyncIntervalMs: 0 } as MakerConfig; + const tracker = new BookTracker(client as never, config, MM_ADDRESS, makeLogger()); + await tracker.start(); + const initial = resyncCount; + // Force lastResyncAt into the past so interval check passes + (tracker as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await tracker.refresh(); + assert.ok(resyncCount > initial, "should have resynced"); + }); + + it("refresh skips resync when interval not elapsed", async () => { + let resyncCount = 0; + const client = { + readContract: async (args: { functionName: string }) => { + if (args.functionName === "getOrderBookPrices") { + resyncCount++; + return [[], []]; + } + if (args.functionName === "getUserOrders") return []; + return undefined; + }, + multicall: async () => [], + watchContractEvent: () => () => {}, + }; + const config = { ...makeConfig(), resyncIntervalMs: 999_999 } as MakerConfig; + const tracker = new BookTracker(client as never, config, MM_ADDRESS, makeLogger()); + await tracker.start(); + const afterStart = resyncCount; + await tracker.refresh(); + assert.equal(resyncCount, afterStart, "should not have resynced yet"); + }); +}); + +describe("BookTracker.handleEvent", () => { + let tracker: BookTracker; + + beforeEach(async () => { + const client = makeEmptyClient(); + tracker = new BookTracker(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await tracker.start(); + }); + + it("OrderCreated adds own order when participant matches", () => { + const orderId = makeOrderId(42); + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderCreated", + args: { participant: MM_ADDRESS, orderId, price: 100_000_000n, quantity: 5_000_000n }, + }); + assert.equal(tracker.ownOrders.size, 1); + assert.equal(tracker.ownOrders.get(orderId)?.quantity, 5_000_000n); + }); + + it("OrderCreated ignores orders from other addresses", () => { + const orderId = makeOrderId(43); + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderCreated", + args: { participant: OTHER_ADDRESS, orderId, price: 100_000_000n, quantity: 5_000_000n }, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderCreated ignores events with missing fields", () => { + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderCreated", + args: { participant: MM_ADDRESS }, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderCancelled removes own order", () => { + const orderId = makeOrderId(44); + tracker.ownOrders.set(orderId, { orderId, price: 100n, quantity: 10n }); + + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderCancelled", + args: { orderId }, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderUpdated modifies existing own order quantity", () => { + const orderId = makeOrderId(45); + tracker.ownOrders.set(orderId, { orderId, price: 100n, quantity: 10n }); + + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderUpdated", + args: { orderId, newQuantity: 5n }, + }); + assert.equal(tracker.ownOrders.get(orderId)?.quantity, 5n); + }); + + it("OrderUpdated removes order when quantity goes to 0", () => { + const orderId = makeOrderId(46); + tracker.ownOrders.set(orderId, { orderId, price: 100n, quantity: 10n }); + + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderUpdated", + args: { orderId, newQuantity: 0n }, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderUpdated ignores unknown order IDs", () => { + const orderId = makeOrderId(99); + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderUpdated", + args: { orderId, newQuantity: 5n }, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderUpdated ignores events with missing fields", () => { + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderUpdated", + args: {}, + }); + assert.equal(tracker.ownOrders.size, 0); + }); + + it("OrderMatched logs but does not crash", () => { + const orderId = makeOrderId(47); + tracker.ownOrders.set(orderId, { orderId, price: 100n, quantity: 10n }); + + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "OrderMatched", + args: { makerOrderId: orderId }, + }); + assert.equal(tracker.ownOrders.size, 1); + }); + + it("handles unknown event names gracefully", () => { + (tracker as unknown as { handleEvent: (log: unknown) => void }).handleEvent({ + eventName: "SomeUnknownEvent", + args: {}, + }); + assert.equal(tracker.ownOrders.size, 0); + }); +}); diff --git a/market-maker/tests-pending/client.test.ts b/market-maker/tests-pending/client.test.ts new file mode 100644 index 0000000..eb80a19 --- /dev/null +++ b/market-maker/tests-pending/client.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { chainMapping, hardhat, createClients } from "../src/client.ts"; +import type { MakerConfig } from "../src/config.ts"; + +function makeConfig(overrides: Partial = {}): MakerConfig { + return { + network: "hardhat", + ethNodeAddress: "http://localhost:8545", + perpsAddress: "0x0000000000000000000000000000000000000001", + makerPrivateKey: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + numLevelsPerSide: 5, + baseQuantity: 1_000_000n, + minSpreadBps: 10, + volatilityMultiplier: 2.0, + inventorySkewGamma: 0.5, + maxSkewTicks: 20, + gasSpikeThresholdPct: 200, + gasCapMultiplier: 2.0, + gasPenaltyBps: 5, + maxGasBudgetPerHourUsd: 50_000_000n, + maxGasBudgetPerDayUsd: 500_000_000n, + urgentRequoteThresholdTicks: 10, + maxPositionSize: 100_000_000n, + maxUtilizationPct: 80, + minCollateralBalance: 100_000_000n, + maxDailyLossUsd: 1_000_000_000n, + pollIntervalMs: 3000, + requoteThresholdTicks: 2, + requoteCooldownMs: 1000, + resyncIntervalMs: 60000, + dryRun: false, + healthPort: 3001, + logLevel: "silent", + ...overrides, + } as MakerConfig; +} + +describe("chainMapping", () => { + it("contains hardhat, arbitrum, and arbitrum-sepolia", () => { + assert.ok("hardhat" in chainMapping); + assert.ok("arbitrum" in chainMapping); + assert.ok("arbitrum-sepolia" in chainMapping); + }); +}); + +describe("hardhat chain", () => { + it("has multicall3 address configured", () => { + assert.ok(hardhat.contracts?.multicall3); + assert.equal( + hardhat.contracts.multicall3.address, + "0xcA11bde05977b3631167028862bE2a173976CA11", + ); + }); +}); + +describe("createClients", () => { + it("throws on unsupported network", () => { + const config = makeConfig({ network: "unknown-chain" }); + assert.throws(() => createClients(config), { + message: /Unsupported network: unknown-chain/, + }); + }); + + it("creates clients for hardhat network with http transport", () => { + const config = makeConfig({ network: "hardhat" }); + const { publicClient, walletClient, account, chain } = createClients(config); + assert.ok(publicClient); + assert.ok(walletClient); + assert.ok(account); + assert.equal(chain.id, hardhat.id); + }); + + it("derives correct account from private key", () => { + const config = makeConfig(); + const { account } = createClients(config); + assert.ok(account.address.startsWith("0x")); + assert.equal(account.address.length, 42); + }); + + it("uses websocket transport when URL starts with ws", () => { + const config = makeConfig({ ethNodeAddress: "ws://localhost:8545" }); + const { publicClient, walletClient } = createClients(config); + assert.ok(publicClient); + assert.ok(walletClient); + }); +}); diff --git a/market-maker/tests-pending/config.test.ts b/market-maker/tests-pending/config.test.ts new file mode 100644 index 0000000..cdf891d --- /dev/null +++ b/market-maker/tests-pending/config.test.ts @@ -0,0 +1,127 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { loadConfig } from "../src/config.ts"; + +const REQUIRED_ENV = { + NETWORK: "hardhat", + ETH_NODE_ADDRESS: "http://localhost:8545", + PERPS_ADDRESS: "0x0000000000000000000000000000000000000001", + MAKER_PRIVATE_KEY: "0x0000000000000000000000000000000000000000000000000000000000000001", +}; + +describe("loadConfig", () => { + let savedEnv: Record; + + beforeEach(() => { + savedEnv = { ...process.env }; + for (const [k, v] of Object.entries(REQUIRED_ENV)) { + process.env[k] = v; + } + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in savedEnv)) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it("loads required fields from env", () => { + const config = loadConfig(); + assert.equal(config.network, "hardhat"); + assert.equal(config.ethNodeAddress, "http://localhost:8545"); + assert.equal(config.perpsAddress, "0x0000000000000000000000000000000000000001"); + assert.equal(config.makerPrivateKey, "0x0000000000000000000000000000000000000000000000000000000000000001"); + }); + + it("throws when required env var is missing", () => { + delete process.env.NETWORK; + assert.throws(() => loadConfig(), { + message: /Missing required environment variable: NETWORK/, + }); + }); + + it("throws for each missing required var", () => { + for (const key of Object.keys(REQUIRED_ENV)) { + delete process.env[key]; + assert.throws(() => loadConfig(), { + message: new RegExp(`Missing required environment variable: ${key}`), + }); + process.env[key] = REQUIRED_ENV[key as keyof typeof REQUIRED_ENV]; + } + }); + + it("applies default values when optional env vars are not set", () => { + const config = loadConfig(); + assert.equal(config.numLevelsPerSide, 5); + assert.equal(config.baseQuantity, 1_000_000n); + assert.equal(config.minSpreadBps, 10); + assert.equal(config.volatilityMultiplier, 2.0); + assert.equal(config.inventorySkewGamma, 0.5); + assert.equal(config.maxSkewTicks, 20); + assert.equal(config.gasSpikeThresholdPct, 200); + assert.equal(config.gasCapMultiplier, 2.0); + assert.equal(config.gasPenaltyBps, 5); + assert.equal(config.maxGasBudgetPerHourUsd, 50_000_000n); + assert.equal(config.maxGasBudgetPerDayUsd, 500_000_000n); + assert.equal(config.urgentRequoteThresholdTicks, 10); + assert.equal(config.maxPositionSize, 100_000_000n); + assert.equal(config.maxUtilizationPct, 80); + assert.equal(config.minCollateralBalance, 100_000_000n); + assert.equal(config.maxDailyLossUsd, 1_000_000_000n); + assert.equal(config.pollIntervalMs, 3000); + assert.equal(config.requoteThresholdTicks, 2); + assert.equal(config.requoteCooldownMs, 1000); + assert.equal(config.resyncIntervalMs, 60000); + assert.equal(config.dryRun, false); + assert.equal(config.healthPort, 3001); + assert.equal(config.logLevel, "info"); + }); + + it("parses custom numeric values from env", () => { + process.env.MAKER_LEVELS_PER_SIDE = "10"; + process.env.MAKER_BASE_QUANTITY = "5000000"; + process.env.MAKER_MIN_SPREAD_BPS = "25"; + process.env.MAKER_POLL_INTERVAL_MS = "5000"; + const config = loadConfig(); + assert.equal(config.numLevelsPerSide, 10); + assert.equal(config.baseQuantity, 5_000_000n); + assert.equal(config.minSpreadBps, 25); + assert.equal(config.pollIntervalMs, 5000); + }); + + it("parses dryRun as true when set", () => { + process.env.MAKER_DRY_RUN = "true"; + assert.equal(loadConfig().dryRun, true); + }); + + it("parses dryRun as false for non-true values", () => { + process.env.MAKER_DRY_RUN = "false"; + assert.equal(loadConfig().dryRun, false); + process.env.MAKER_DRY_RUN = "1"; + assert.equal(loadConfig().dryRun, false); + }); + + it("parses logLevel from env", () => { + process.env.MAKER_LOG_LEVEL = "debug"; + assert.equal(loadConfig().logLevel, "debug"); + }); + + it("defaults logLevel to info when env not set", () => { + delete process.env.MAKER_LOG_LEVEL; + assert.equal(loadConfig().logLevel, "info"); + }); + + it("parses ethPriceFeedAddress when set", () => { + process.env.ETH_PRICE_FEED_ADDRESS = "0xaabbccdd00000000000000000000000000000002"; + assert.equal(loadConfig().ethPriceFeedAddress, "0xaabbccdd00000000000000000000000000000002"); + }); + + it("returns undefined ethPriceFeedAddress when not set", () => { + delete process.env.ETH_PRICE_FEED_ADDRESS; + assert.equal(loadConfig().ethPriceFeedAddress, undefined); + }); +}); diff --git a/market-maker/tests-pending/healthcheck.test.ts b/market-maker/tests-pending/healthcheck.test.ts new file mode 100644 index 0000000..fd21b43 --- /dev/null +++ b/market-maker/tests-pending/healthcheck.test.ts @@ -0,0 +1,346 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { HealthCheck } from "../src/healthcheck.ts"; +import type { MakerConfig } from "../src/config.ts"; +import type { OracleTracker } from "../src/oracleTracker.ts"; +import type { InventoryManager } from "../src/inventoryManager.ts"; +import type { BookTracker } from "../src/bookTracker.ts"; +import type { GasTracker } from "../src/gasTracker.ts"; +import type { RiskManager } from "../src/riskManager.ts"; +import pino from "pino"; + +const noop = () => {}; +function makeLogger(): never { + return { info: noop, warn: noop, error: noop, child: () => makeLogger() } as never; +} + +let nextPort = 19000; + +function makeDeps() { + const port = nextPort++; + const config = { + healthPort: port, + network: "hardhat", + nodeEnv: "development", + perpsAddress: "0x1234", + dryRun: false, + logLevel: "info", + commitHash: "abc123", + numLevelsPerSide: 5, + baseQuantity: 1_000_000n, + minSpreadBps: 10, + volatilityMultiplier: 2.0, + inventorySkewGamma: 0.5, + maxSkewTicks: 20, + ethPriceFeedAddress: undefined, + gasSpikeThresholdPct: 200, + gasCapMultiplier: 2.0, + gasPenaltyBps: 5, + maxGasBudgetPerHourUsd: 50_000_000n, + maxGasBudgetPerDayUsd: 500_000_000n, + urgentRequoteThresholdTicks: 10, + maxPositionSize: 100_000_000n, + maxUtilizationPct: 80, + minCollateralBalance: 100_000_000n, + maxDailyLossUsd: 1_000_000_000n, + pollIntervalMs: 3000, + requoteThresholdTicks: 2, + requoteCooldownMs: 1000, + resyncIntervalMs: 60_000, + } as MakerConfig; + const oracle = { currentPrice: 100_000_000n, volatility: 0.005 } as OracleTracker; + const inventory = { + netQuantity: 5_000_000n, + collateralBalance: 500_000_000n, + inventorySkew: 0.05, + utilizationPct: 15, + tokenBalance: 500_000_000n, + ethBalance: 500_000_000n, + } as InventoryManager; + const book = { + ownOrders: new Map(), + bestBid: 99_000_000n, + bestAsk: 101_000_000n, + } as unknown as BookTracker; + const gas = { + currentGasPrice: 1_000_000_000n, + isGasSpiking: false, + gasSpikePct: 10, + } as GasTracker; + const risk = { + halted: false, + haltReason: null, + throttled: false, + throttleReason: "none", + cumulativeGasCostUsd: 50_000n, + } as unknown as RiskManager; + + return { config, oracle, inventory, book, gas, risk, port }; +} + +describe("HealthCheck", () => { + let health: HealthCheck | null = null; + + afterEach(async () => { + await health?.stop(); + health = null; + }); + + it("starts and responds to /health with JSON", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, pino()); + health.status = "running"; + console.log("starting health"); + await health.start(); + console.log("health started", port); + + const res = await fetch(`http://localhost:${port}/health`); + console.log("===========", res); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "application/json"); + + const body = await res.json(); + assert.equal(body.status, "running"); + assert.ok(typeof body.uptimeSeconds === "number"); + + assert.equal(body.config.network, "hardhat"); + assert.equal(body.config.dryRun, false); + assert.equal(body.config.commitHash, "abc123"); + assert.equal(body.config.quoting.numLevelsPerSide, 5); + assert.equal(body.config.quoting.baseQuantity, "1000000"); + assert.equal(body.config.quoting.minSpreadBps, 10); + assert.equal(body.config.gas.gasSpikeThresholdPct, 200); + assert.equal(body.config.risk.maxPositionSize, "100000000"); + assert.equal(body.config.timing.pollIntervalMs, 3000); + + assert.equal(body.market.oraclePrice, "100000000"); + assert.equal(body.market.bestBid, "99000000"); + assert.equal(body.market.bestAsk, "101000000"); + + assert.equal(body.inventory.netPosition, "5000000"); + assert.equal(body.inventory.collateralBalance, "500000000"); + assert.equal(body.inventory.ethBalance, "500000000"); + assert.equal(body.inventory.tokenBalance, "500000000"); + assert.equal(body.inventory.inventorySkew, 0.05); + assert.equal(body.inventory.utilizationPct, 15); + + assert.equal(body.gas.gasSpiking, false); + assert.equal(body.risk.throttled, false); + }); + + it("reports initializing status before init completes", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + + const res = await fetch(`http://localhost:${port}/health`); + const body = await res.json(); + assert.equal(body.status, "initializing"); + assert.equal(body.lastError, null); + }); + + it("reports init-error status with lastError on init failure", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "init-error"; + health.lastError = { message: "insufficient funds for gas" }; + await health.start(); + + const res = await fetch(`http://localhost:${port}/health`); + const body = await res.json(); + assert.equal(body.status, "init-error"); + assert.equal(body.lastError.message, "insufficient funds for gas"); + }); + + it("reports error status when tick fails", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "error"; + health.lastError = { message: "execution reverted" }; + await health.start(); + + const res = await fetch(`http://localhost:${port}/health`); + const body = await res.json(); + assert.equal(body.status, "error"); + assert.equal(body.lastError.message, "execution reverted"); + }); + + it("reports error status when risk is halted", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "error"; + health.lastError = { message: "collateral below minimum", balance: "0", min: "100000000" }; + await health.start(); + + const res = await fetch(`http://localhost:${port}/health`); + const body = await res.json(); + assert.equal(body.status, "error"); + assert.equal(body.lastError.message, "collateral below minimum"); + assert.equal(body.lastError.balance, "0"); + }); + + it("returns 404 for non-health paths", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + + const res = await fetch(`http://localhost:${port}/other`); + assert.equal(res.status, 404); + }); + + it("returns 404 for POST to /health", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + + const res = await fetch(`http://localhost:${port}/health`, { method: "POST" }); + assert.equal(res.status, 404); + }); + + it("stop is idempotent", async () => { + const { config, oracle, inventory, book, gas, risk } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + await health.stop(); + await health.stop(); + health = null; + }); + + it("stop without start does not throw", async () => { + const { config, oracle, inventory, book, gas, risk } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.stop(); + health = null; + }); + + it("POST /stop sets status to stopped and paused flag", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + await health.start(); + + const res = await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.status, "stopped"); + + assert.equal(health.paused, true); + assert.equal(health.status, "stopped"); + + const healthRes = await fetch(`http://localhost:${port}/health`); + const healthBody = await healthRes.json(); + assert.equal(healthBody.status, "stopped"); + }); + + it("POST /start resumes from stopped state", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + await health.start(); + + await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(health.paused, true); + + const res = await fetch(`http://localhost:${port}/start`, { method: "POST" }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.status, "running"); + + assert.equal(health.paused, false); + assert.equal(health.status, "running"); + }); + + it("POST /stop is idempotent", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + await health.start(); + + const res1 = await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(res1.status, 200); + + const res2 = await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(res2.status, 200); + assert.equal(health.paused, true); + }); + + it("POST /start when already running is a no-op", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + await health.start(); + + const res = await fetch(`http://localhost:${port}/start`, { method: "POST" }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.status, "running"); + assert.equal(health.paused, false); + }); + + it("POST /stop calls onStop callback", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + let callbackCalled = false; + health.onStop = async () => { + callbackCalled = true; + }; + await health.start(); + + const res = await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(res.status, 200); + assert.equal(callbackCalled, true); + }); + + it("POST /start calls onStart callback", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + let callbackCalled = false; + health.onStart = async () => { + callbackCalled = true; + }; + await health.start(); + + await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + const res = await fetch(`http://localhost:${port}/start`, { method: "POST" }); + assert.equal(res.status, 200); + assert.equal(callbackCalled, true); + }); + + it("POST /stop returns 500 when onStop callback fails", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + health.status = "running"; + health.onStop = async () => { + throw new Error("cancel failed"); + }; + await health.start(); + + const res = await fetch(`http://localhost:${port}/stop`, { method: "POST" }); + assert.equal(res.status, 500); + const body = await res.json(); + assert.equal(body.ok, false); + }); + + it("GET /stop returns 404", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + + const res = await fetch(`http://localhost:${port}/stop`); + assert.equal(res.status, 404); + }); + + it("GET /start returns 404", async () => { + const { config, oracle, inventory, book, gas, risk, port } = makeDeps(); + health = new HealthCheck(config, oracle, inventory, book, gas, risk, makeLogger()); + await health.start(); + + const res = await fetch(`http://localhost:${port}/start`); + assert.equal(res.status, 404); + }); +}); diff --git a/market-maker/tests-pending/helpers.ts b/market-maker/tests-pending/helpers.ts new file mode 100644 index 0000000..2084fcc --- /dev/null +++ b/market-maker/tests-pending/helpers.ts @@ -0,0 +1,59 @@ +export { + HARDHAT_ACCOUNTS, + RPC_URL, + hardhat, + startHardhatNode, + waitFor, + sleep, + loadFixture, + createTestPublicClient, + createTestWalletClient, + createTestClientInstance, + type HardhatNode, +} from "../../contracts/fixtures/helpers.ts"; + +import type { Address } from "viem"; +import type { MakerConfig } from "../src/config.ts"; +import { HARDHAT_ACCOUNTS, RPC_URL } from "../../contracts/fixtures/helpers.ts"; +import { parseUnits } from "viem"; + +export function createMakerConfig( + perpsAddress: Address, + overrides: Partial = {}, +): MakerConfig { + return { + network: "hardhat", + ethNodeAddress: RPC_URL, + perpsAddress, + makerPrivateKey: HARDHAT_ACCOUNTS[3].privateKey, + + numLevelsPerSide: 3, + baseQuantity: parseUnits("1", 6), + minSpreadBps: 50, + volatilityMultiplier: 0, + inventorySkewGamma: 0.5, + maxSkewTicks: 20, + + gasSpikeThresholdPct: 200, + gasCapMultiplier: 5.0, + gasPenaltyBps: 0, + maxGasBudgetPerHourUsd: 999_000_000n, + maxGasBudgetPerDayUsd: 9_999_000_000n, + urgentRequoteThresholdTicks: 10, + + maxPositionSize: parseUnits("100", 6), + maxUtilizationPct: 90, + minCollateralBalance: 1n, + maxDailyLossUsd: 999_000_000_000n, + + pollIntervalMs: 200, + requoteThresholdTicks: 1, + requoteCooldownMs: 0, + resyncIntervalMs: 60_000, + + dryRun: false, + healthPort: 0, + logLevel: "silent", + ...overrides, + } as MakerConfig; +} diff --git a/market-maker/tests-pending/inventoryManager.test.ts b/market-maker/tests-pending/inventoryManager.test.ts new file mode 100644 index 0000000..0e7dfe7 --- /dev/null +++ b/market-maker/tests-pending/inventoryManager.test.ts @@ -0,0 +1,156 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { InventoryManager } from "../src/inventoryManager.ts"; +import type { MakerConfig } from "../src/config.ts"; + +function makeConfig(overrides: Partial = {}): MakerConfig { + return { + perpsAddress: "0x0000000000000000000000000000000000000001", + maxPositionSize: 100_000_000n, + ...overrides, + } as MakerConfig; +} + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +const MM_ADDRESS = "0x0000000000000000000000000000000000000099" as `0x${string}`; +const TOKEN_ADDRESS = "0x0000000000000000000000000000000000000042" as `0x${string}`; +const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" as `0x${string}`; + +function makeMockClient(multicallResults: unknown[]) { + return { + readContract: async () => TOKEN_ADDRESS, + chain: { contracts: { multicall3: { address: MULTICALL3_ADDRESS } } }, + multicall: async () => multicallResults, + }; +} + +describe("InventoryManager", () => { + it("starts with zero values", () => { + const inv = new InventoryManager({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + assert.equal(inv.netQuantity, 0n); + assert.equal(inv.collateralBalance, 0n); + assert.equal(inv.inventorySkew, 0); + assert.equal(inv.hasPosition, false); + assert.equal(inv.absPosition, 0n); + }); + + it("hasPosition returns true for non-zero netQuantity", () => { + const inv = new InventoryManager({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + inv.netQuantity = 50_000_000n; + assert.equal(inv.hasPosition, true); + }); + + it("hasPosition returns true for negative netQuantity", () => { + const inv = new InventoryManager({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + inv.netQuantity = -30_000_000n; + assert.equal(inv.hasPosition, true); + }); + + it("absPosition returns absolute value of negative position", () => { + const inv = new InventoryManager({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + inv.netQuantity = -75_000_000n; + assert.equal(inv.absPosition, 75_000_000n); + }); + + it("absPosition returns positive position unchanged", () => { + const inv = new InventoryManager({} as never, makeConfig(), MM_ADDRESS, makeLogger()); + inv.netQuantity = 50_000_000n; + assert.equal(inv.absPosition, 50_000_000n); + }); + + it("update sets fields from multicall results", async () => { + const client = makeMockClient([ + { netQuantity: 10_000_000n, aggregatedEntryPrice: 50_000_000n }, + 500_000_000n, + 250_000_000n, + 100_000_000n, + 1_000_000_000_000_000_000n, + ]); + const inv = new InventoryManager(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await inv.update(); + + assert.equal(inv.netQuantity, 10_000_000n); + assert.equal(inv.entryPrice, 50_000_000n); + assert.equal(inv.collateralBalance, 500_000_000n); + assert.equal(inv.tokenBalance, 250_000_000n); + assert.equal(inv.requiredMargin, 100_000_000n); + assert.equal(inv.ethBalance, 1_000_000_000_000_000_000n); + assert.equal(inv.availableMargin, 400_000_000n); + assert.equal(inv.utilizationPct, 20); + assert.ok(inv.inventorySkew > 0, "positive net qty → positive skew"); + }); + + it("update handles failed multicall results gracefully", async () => { + const client = { + readContract: async () => TOKEN_ADDRESS, + chain: { contracts: { multicall3: { address: MULTICALL3_ADDRESS } } }, + multicall: async () => { throw new Error("multicall failed"); }, + }; + const inv = new InventoryManager(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await assert.rejects(() => inv.update(), { message: "multicall failed" }); + + assert.equal(inv.netQuantity, 0n); + assert.equal(inv.collateralBalance, 0n); + assert.equal(inv.utilizationPct, 0); + }); + + it("clamps inventory skew to [-1, 1]", async () => { + const client = makeMockClient([ + { netQuantity: 999_000_000n, aggregatedEntryPrice: 50_000_000n }, + 1_000_000_000n, + 0n, + 0n, + 0n, + ]); + const inv = new InventoryManager(client as never, makeConfig({ maxPositionSize: 100_000_000n }), MM_ADDRESS, makeLogger()); + await inv.update(); + + assert.equal(inv.inventorySkew, 1); + }); + + it("clamps negative inventory skew to -1", async () => { + const client = makeMockClient([ + { netQuantity: -999_000_000n, aggregatedEntryPrice: 50_000_000n }, + 1_000_000_000n, + 0n, + 0n, + 0n, + ]); + const inv = new InventoryManager(client as never, makeConfig({ maxPositionSize: 100_000_000n }), MM_ADDRESS, makeLogger()); + await inv.update(); + + assert.equal(inv.inventorySkew, -1); + }); + + it("sets availableMargin to 0 when requiredMargin exceeds collateral", async () => { + const client = makeMockClient([ + { netQuantity: 0n, aggregatedEntryPrice: 0n }, + 100_000_000n, + 0n, + 200_000_000n, + 0n, + ]); + const inv = new InventoryManager(client as never, makeConfig(), MM_ADDRESS, makeLogger()); + await inv.update(); + + assert.equal(inv.availableMargin, 0n); + }); + + it("sets skew to 0 when maxPositionSize is 0", async () => { + const client = makeMockClient([ + { netQuantity: 10_000_000n, aggregatedEntryPrice: 50_000_000n }, + 500_000_000n, + 0n, + 100_000_000n, + 0n, + ]); + const inv = new InventoryManager(client as never, makeConfig({ maxPositionSize: 0n }), MM_ADDRESS, makeLogger()); + await inv.update(); + + assert.equal(inv.inventorySkew, 0); + }); +}); diff --git a/market-maker/tests-pending/market-maker.e2e.test.ts b/market-maker/tests-pending/market-maker.e2e.test.ts new file mode 100644 index 0000000..654338b --- /dev/null +++ b/market-maker/tests-pending/market-maker.e2e.test.ts @@ -0,0 +1,535 @@ +import { describe, it, before, after, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { getContract, parseUnits } from "viem"; +import pino from "pino"; + +import { OracleTracker } from "../src/oracleTracker.ts"; +import { GasTracker } from "../src/gasTracker.ts"; +import { BookTracker } from "../src/bookTracker.ts"; +import { InventoryManager } from "../src/inventoryManager.ts"; +import { Quoter } from "../src/quoter.ts"; +import { OrderExecutor } from "../src/orderExecutor.ts"; +import { RiskManager } from "../src/riskManager.ts"; +import { HealthCheck } from "../src/healthcheck.ts"; +import type { MakerConfig } from "../src/config.ts"; +import { hashPowerPerpsDexAbi, priceOracleMockAbi } from "../src/abi.ts"; +import { hardhat } from "../src/client.ts"; +import { startHardhatNode, createMakerConfig, loadFixture, type HardhatNode } from "./helpers.ts"; +import { deployWithCollateralFixture } from "../../contracts/fixtures/viem.ts"; + +const silentLogger = pino({ level: "silent" }); + +let hardhatNode: HardhatNode; + +before(async () => { + hardhatNode = await startHardhatNode(); +}); + +after(() => { + hardhatNode.stop(); +}); + +// ── Component wiring helper ───────────────────────────────────────────────── + +interface MakerStack { + config: MakerConfig; + oracle: OracleTracker; + gas: GasTracker; + book: BookTracker; + inventory: InventoryManager; + risk: RiskManager; + quoter: Quoter; + executor: OrderExecutor; + health: HealthCheck; +} + +function createStack( + deployment: Awaited>, + configOverrides: Partial = {}, +): MakerStack { + const { clients, contracts } = deployment; + const config = createMakerConfig(contracts.perpsAddress, configOverrides); + + const { publicClient } = clients; + const mmWallet = clients.buyer2Wallet; + const mmAddress = mmWallet.account.address; + + const oracle = new OracleTracker(publicClient, config, silentLogger); + const gas = new GasTracker(publicClient, config, silentLogger); + const book = new BookTracker(publicClient, config, mmAddress, silentLogger); + const inventory = new InventoryManager(publicClient, config, mmAddress, silentLogger); + const risk = new RiskManager(config, inventory, gas, oracle, silentLogger); + const quoter = new Quoter(publicClient, config, oracle, gas, inventory, risk, silentLogger); + const executor = new OrderExecutor( + publicClient, + mmWallet, + mmWallet.account, + hardhat, + config, + quoter, + book, + gas, + risk, + oracle, + silentLogger, + ); + const health = new HealthCheck(config, oracle, inventory, book, gas, risk, silentLogger); + + return { config, oracle, gas, book, inventory, risk, quoter, executor, health }; +} + +async function initStack(stack: MakerStack): Promise { + await stack.quoter.initialize(); + await stack.book.start(); + await stack.oracle.update(); + await stack.gas.update(); + await stack.inventory.update(); + stack.risk.initialize(); + stack.health.status = "running"; +} + +function stopStack(stack: MakerStack): void { + stack.book.stop(); + stack.health.stop(); +} + +// ── Quoting tests ─────────────────────────────────────────────────────────── + +describe("MM quoting", () => { + let deployment: Awaited>; + let stack: MakerStack; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + stack = createStack(deployment); + await initStack(stack); + }); + + afterEach(async () => { + try { + await stack.executor.cancelAll(); + } catch { + /* may already be cancelled */ + } + stopStack(stack); + }); + + it("should read real oracle price", async () => { + assert.ok(stack.oracle.currentPrice > 0n, "oracle price should be positive"); + + const onChainPrice = await deployment.clients.publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getMarketPrice", + }); + assert.equal(stack.oracle.currentPrice, onChainPrice); + }); + + it("should place orders on an empty book", async () => { + const desired = stack.quoter.computeQuotes(); + assert.ok(desired.bids.length > 0, "should have bid quotes"); + assert.ok(desired.asks.length > 0, "should have ask quotes"); + + await stack.executor.reconcile(desired); + + // Verify orders appeared on-chain + await stack.book.refresh(); + // Force resync to pick up the orders + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + + assert.ok(stack.book.ownOrders.size > 0, "MM should have resting orders on the book"); + }); + + it("should place bids below and asks above oracle price", async () => { + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + const oraclePrice = stack.oracle.currentPrice; + for (const bid of desired.bids) { + assert.ok(bid.price < oraclePrice, `bid ${bid.price} should be below oracle ${oraclePrice}`); + assert.ok(bid.quantity > 0n, "bid quantity should be positive"); + } + for (const ask of desired.asks) { + assert.ok(ask.price > oraclePrice, `ask ${ask.price} should be above oracle ${oraclePrice}`); + assert.ok(ask.quantity < 0n, "ask quantity should be negative"); + } + }); + + it("should produce multiple levels with increasing size", async () => { + const desired = stack.quoter.computeQuotes(); + + assert.equal(desired.bids.length, stack.config.numLevelsPerSide); + assert.equal(desired.asks.length, stack.config.numLevelsPerSide); + + for (let i = 1; i < desired.bids.length; i++) { + assert.ok( + desired.bids[i].quantity > desired.bids[i - 1].quantity, + "deeper levels should have larger size", + ); + assert.ok( + desired.bids[i].price < desired.bids[i - 1].price, + "deeper bid levels should have lower price", + ); + } + }); + + it("should cancel all orders on cancelAll", async () => { + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.ok(stack.book.ownOrders.size > 0, "should have orders before cancel"); + + await stack.executor.cancelAll(); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.equal(stack.book.ownOrders.size, 0, "all orders should be cancelled"); + }); + + it("should not place orders in dry-run mode", async () => { + stopStack(stack); + stack = createStack(deployment, { dryRun: true }); + await initStack(stack); + + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.equal(stack.book.ownOrders.size, 0, "dry run should not place real orders"); + }); +}); + +// ── Fill handling tests ───────────────────────────────────────────────────── + +describe("MM fill handling", () => { + let deployment: Awaited>; + let stack: MakerStack; + let perps: ReturnType; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + stack = createStack(deployment); + await initStack(stack); + + perps = deployment.contracts.perps; + }); + + afterEach(async () => { + try { + await stack.executor.cancelAll(); + } catch { + /* may already be cancelled */ + } + stopStack(stack); + }); + + it("should update inventory after a fill", async () => { + assert.equal(stack.inventory.netQuantity, 0n, "MM starts flat"); + + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + // Taker buys into the MM's best ask (fills the MM's sell order) + const bestAsk = desired.asks[0]; + const takerQty = parseUnits("1", deployment.config.quantityDecimals); + + await ( + perps as unknown as { + write: { createOrder: (args: [bigint, bigint], opts: unknown) => Promise }; + } + ).write.createOrder([bestAsk.price, takerQty], { + account: deployment.clients.buyerWallet.account, + }); + + await stack.inventory.update(); + assert.ok(stack.inventory.netQuantity < 0n, "MM should be short after selling to taker"); + assert.ok(stack.inventory.hasPosition, "MM should have a position"); + }); + + it("should requote after fill changes inventory", async () => { + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + // Fill the MM's ask + const bestAsk = desired.asks[0]; + const takerQty = parseUnits("1", deployment.config.quantityDecimals); + await ( + perps as unknown as { + write: { createOrder: (args: [bigint, bigint], opts: unknown) => Promise }; + } + ).write.createOrder([bestAsk.price, takerQty], { + account: deployment.clients.buyerWallet.account, + }); + + // Update state + await stack.oracle.update(); + await stack.inventory.update(); + + // Compute new quotes with updated inventory + const newDesired = stack.quoter.computeQuotes(); + + // With short inventory and positive skew gamma, bids should be more aggressive (higher) + // to attract buys and reduce short exposure + assert.ok(newDesired.bids.length > 0, "should still quote bids"); + assert.ok(newDesired.asks.length > 0, "should still quote asks"); + }); +}); + +// ── Requote on price change ───────────────────────────────────────────────── + +describe("MM requote on price change", () => { + let deployment: Awaited>; + let stack: MakerStack; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + stack = createStack(deployment, { requoteCooldownMs: 0, requoteThresholdTicks: 1 }); + await initStack(stack); + }); + + afterEach(async () => { + try { + await stack.executor.cancelAll(); + } catch { + /* may already be cancelled */ + } + stopStack(stack); + }); + + it("should adjust quotes when oracle price changes", async () => { + const desired1 = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired1); + const bid1 = desired1.bids[0].price; + const ask1 = desired1.asks[0].price; + + // Change oracle price significantly + const oracle = getContract({ + address: deployment.contracts.oracleAddress, + abi: priceOracleMockAbi, + client: { wallet: deployment.clients.ownerWallet }, + }); + const newPrice = deployment.config.oracle.price * 2n; + await oracle.write.setPrice([newPrice, deployment.config.oracle.decimals]); + + await stack.oracle.update(); + const desired2 = stack.quoter.computeQuotes(); + + assert.ok(desired2.bids[0].price > bid1, "bid should move up with higher oracle"); + assert.ok(desired2.asks[0].price > ask1, "ask should move up with higher oracle"); + }); +}); + +// ── Risk controls ─────────────────────────────────────────────────────────── + +describe("MM risk controls", () => { + let deployment: Awaited>; + let stack: MakerStack; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + }); + + afterEach(async () => { + if (stack) { + try { + await stack.executor.cancelAll(); + } catch { + /* may already be cancelled */ + } + stopStack(stack); + } + }); + + it("should halt when collateral drops below minimum", async () => { + // Set minCollateralBalance very high so the MM immediately halts + stack = createStack(deployment, { minCollateralBalance: 999_999_000_000n }); + await initStack(stack); + + const ok = stack.risk.check(); + assert.equal(ok, false, "risk check should fail"); + assert.equal(stack.risk.halted, true); + assert.equal(stack.risk.haltReason?.message, "collateral below minimum"); + }); + + it("should block bid side when at max long position", async () => { + stack = createStack(deployment, { maxPositionSize: 1n, maxUtilizationPct: 90 }); + await initStack(stack); + + // Simulate a long position by setting inventory + stack.inventory.netQuantity = 1n; + stack.inventory.utilizationPct = 95; + + const sides = stack.risk.allowedSides(); + assert.equal(sides.quoteBid, false, "should block bids at max long"); + assert.equal(sides.quoteAsk, true, "should allow asks to reduce position"); + }); + + it("should block ask side when at max short position", async () => { + stack = createStack(deployment, { maxPositionSize: 1n, maxUtilizationPct: 90 }); + await initStack(stack); + + stack.inventory.netQuantity = -1n; + stack.inventory.utilizationPct = 95; + + const sides = stack.risk.allowedSides(); + assert.equal(sides.quoteBid, true, "should allow bids to reduce position"); + assert.equal(sides.quoteAsk, false, "should block asks at max short"); + }); + + it("should cancel all orders when risk halts", async () => { + stack = createStack(deployment); + await initStack(stack); + + // Place some orders first + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.ok(stack.book.ownOrders.size > 0, "should have orders before halt"); + + // Trigger halt + stack.inventory.collateralBalance = 0n; + const ok = stack.risk.check(); + assert.equal(ok, false); + + // Halt should cancel all + await stack.executor.cancelAll(); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.equal(stack.book.ownOrders.size, 0, "all orders cancelled after halt"); + }); +}); + +// ── Health endpoint ───────────────────────────────────────────────────────── + +describe("MM health endpoint", () => { + let deployment: Awaited>; + let stack: MakerStack; + let healthPort: number; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + healthPort = 19100 + Math.floor(Math.random() * 900); + stack = createStack(deployment, { healthPort }); + await initStack(stack); + await stack.health.start(); + }); + + afterEach(async () => { + stopStack(stack); + }); + + it("should expose running status and live data", async () => { + const res = await fetch(`http://localhost:${healthPort}/health`); + assert.equal(res.status, 200); + + const body = await res.json(); + assert.equal(body.status, "running"); + assert.ok(BigInt(body.market.oraclePrice) > 0n, "oraclePrice should be positive"); + assert.ok(BigInt(body.inventory.collateralBalance) > 0n, "collateral should be positive"); + assert.equal(body.gas.gasSpiking, false); + assert.equal(body.config.dryRun, false); + assert.ok(typeof body.uptimeSeconds === "number"); + }); + + it("should show error status after risk halt", async () => { + stack.inventory.collateralBalance = 0n; + stack.risk.check(); + stack.health.status = "error"; + stack.health.lastError = stack.risk.haltReason; + + const res = await fetch(`http://localhost:${healthPort}/health`); + const body = await res.json(); + assert.equal(body.status, "error"); + assert.equal(body.lastError.message, "collateral below minimum"); + assert.equal(body.lastError.balance, "0"); + }); +}); + +// ── Full tick cycle ───────────────────────────────────────────────────────── + +describe("MM full tick cycle", () => { + let deployment: Awaited>; + let stack: MakerStack; + + beforeEach(async () => { + deployment = await loadFixture(deployWithCollateralFixture); + stack = createStack(deployment); + await initStack(stack); + }); + + afterEach(async () => { + try { + await stack.executor.cancelAll(); + } catch { + /* may already be cancelled */ + } + stopStack(stack); + }); + + it("should complete a full tick: update → check → quote → reconcile", async () => { + await stack.oracle.update(); + await stack.gas.update(); + await stack.inventory.update(); + + const ok = stack.risk.check(); + assert.ok(ok, "risk check should pass"); + + const desired = stack.quoter.computeQuotes(); + assert.ok(desired.bids.length > 0); + assert.ok(desired.asks.length > 0); + + await stack.executor.reconcile(desired); + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.ok(stack.book.ownOrders.size > 0, "orders should exist after tick"); + }); + + it("should handle multiple consecutive ticks", async () => { + for (let i = 0; i < 3; i++) { + await stack.oracle.update(); + await stack.gas.update(); + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + await stack.inventory.update(); + + const ok = stack.risk.check(); + if (!ok) { + await stack.executor.cancelAll(); + continue; + } + + const desired = stack.quoter.computeQuotes(); + await stack.executor.reconcile(desired); + } + + (stack.book as unknown as { lastResyncAt: number }).lastResyncAt = 0; + await stack.book.refresh(); + assert.ok(stack.book.ownOrders.size > 0, "should have orders after multiple ticks"); + }); + + it("should survive oracle price going to zero gracefully", async () => { + // Zero out the oracle + const oracle = getContract({ + address: deployment.contracts.oracleAddress, + abi: priceOracleMockAbi, + client: { wallet: deployment.clients.ownerWallet }, + }); + await oracle.write.setPrice([0n, deployment.config.oracle.decimals]); + + await stack.oracle.update(); + assert.equal(stack.oracle.currentPrice, 0n); + + // Quoter should return empty quotes, no crash + const desired = stack.quoter.computeQuotes(); + assert.equal(desired.bids.length, 0); + assert.equal(desired.asks.length, 0); + }); +}); diff --git a/market-maker/tests-pending/market-maker.process.test.ts b/market-maker/tests-pending/market-maker.process.test.ts new file mode 100644 index 0000000..ab5d4fd --- /dev/null +++ b/market-maker/tests-pending/market-maker.process.test.ts @@ -0,0 +1,655 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { resolve } from "node:path"; +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { getContract, parseUnits, type Hex } from "viem"; + +import { hashPowerPerpsDexAbi, priceOracleMockAbi } from "../src/abi.ts"; +import { + startHardhatNode, + waitFor, + sleep, + createTestPublicClient, + createTestWalletClient, + createTestClientInstance, + HARDHAT_ACCOUNTS, + type HardhatNode, +} from "./helpers.ts"; +import { deployWithCollateralFixture } from "../../contracts/fixtures/viem.ts"; + +const MM_ACCOUNT = HARDHAT_ACCOUNTS[3]; +const TAKER_ACCOUNT = HARDHAT_ACCOUNTS[2]; +const OWNER_ACCOUNT = HARDHAT_ACCOUNTS[0]; +const HEALTH_PORT = 19950; + +// ── Shared state across the whole file ────────────────────────────────────── + +let hardhatNode: HardhatNode; +let deployment: Awaited>; +let baseSnapshotId: Hex; + +before(async () => { + hardhatNode = await startHardhatNode(); + deployment = await deployWithCollateralFixture(); + const tc = createTestClientInstance(); + baseSnapshotId = await tc.snapshot(); +}); + +after(() => { + hardhatNode.stop(); +}); + +// ── MM process helpers ────────────────────────────────────────────────────── + +interface MakerProcess { + child: ChildProcess; + exited: Promise; + port: number; +} + +function spawnMM(port: number): MakerProcess { + const child = spawn("node", ["src/index.ts"], { + cwd: resolve(import.meta.dirname, ".."), + env: { + ...process.env, + NETWORK: "hardhat", + ETH_NODE_ADDRESS: "http://127.0.0.1:8545", + PERPS_ADDRESS: deployment.contracts.perpsAddress, + MAKER_PRIVATE_KEY: MM_ACCOUNT.privateKey, + MAKER_HEALTH_PORT: String(port), + MAKER_LOG_LEVEL: "silent", + MAKER_POLL_INTERVAL_MS: "500", + MAKER_RESYNC_INTERVAL_MS: "500", + MAKER_LEVELS_PER_SIDE: "3", + MAKER_MIN_SPREAD_BPS: "50", + MAKER_REQUOTE_THRESHOLD_TICKS: "1", + MAKER_REQUOTE_COOLDOWN_MS: "0", + MAKER_BASE_QUANTITY: String(parseUnits("1", deployment.config.quantityDecimals)), + MAKER_VOLATILITY_MULTIPLIER: "0", + MAKER_INVENTORY_SKEW_GAMMA: "0.5", + MAKER_MAX_SKEW_TICKS: "20", + MAKER_MAX_POSITION_SIZE: String(parseUnits("100", deployment.config.quantityDecimals)), + MAKER_MAX_UTILIZATION_PCT: "90", + MAKER_MIN_COLLATERAL: "1", + MAKER_MAX_DAILY_LOSS_USD: "999000000000", + MAKER_GAS_CAP_MULTIPLIER: "5.0", + MAKER_GAS_PENALTY_BPS: "0", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + + const exited = new Promise((r) => child.on("close", r)); + + return { child, exited, port }; +} + +async function stopMM(mm: MakerProcess): Promise { + if (!mm.child.killed) { + mm.child.kill("SIGTERM"); + await Promise.race([mm.exited, sleep(5_000)]); + } +} + +async function fetchHealth(port: number): Promise> { + const res = await fetch(`http://localhost:${port}/health`); + return res.json() as Promise>; +} + +async function waitForReady(mm: MakerProcess): Promise { + // Race health polling against process exit to avoid hanging + await Promise.race([ + waitFor(async () => { + try { + const h = await fetchHealth(mm.port); + return (h.market?.ownOrders as number) > 0 && h.market?.bestAsk !== "0"; + } catch { + return false; + } + }, 30_000), + mm.exited.then((code) => { + throw new Error(`MM process exited unexpectedly with code ${code}`); + }), + ]); +} + +async function revertToBase(): Promise { + const tc = createTestClientInstance(); + await tc.revert({ id: baseSnapshotId }); + baseSnapshotId = await tc.snapshot(); +} + +// ── Test group 1: quoting, monitoring, fills, requotes ────────────────────── + +describe("MM process — quoting and fills", () => { + let mm: MakerProcess; + + before(async () => { + mm = spawnMM(HEALTH_PORT); + await waitForReady(mm); + }); + + after(async () => { + await stopMM(mm); + }); + + it("should report healthy status via API", async () => { + const h = await fetchHealth(mm.port); + assert.equal(h.status, "running"); + const stats = h.stats as Record; + assert.ok((stats.tickCount as number) >= 1); + assert.ok((stats.lastTickAt as number) > 0); + assert.equal((h.config as Record).dryRun, false); + assert.equal((h.gas as Record).gasSpiking, false); + }); + + it("should have resting orders on-chain", async () => { + const publicClient = createTestPublicClient(); + const orders = await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + }); + assert.ok((orders as unknown[]).length > 0); + }); + + it("should report order placement stats via API", async () => { + const h = await fetchHealth(mm.port); + const stats = h.stats as Record; + assert.ok((stats.ordersPlaced as number) > 0); + assert.ok((stats.reconcileCount as number) > 0); + }); + + it("should place bids below and asks above oracle", async () => { + const h = await fetchHealth(mm.port); + const market = h.market as Record; + const oracle = BigInt(market.oraclePrice as string); + const bid = BigInt(market.bestBid as string); + const ask = BigInt(market.bestAsk as string); + + assert.ok(bid > 0n && bid < oracle, "bid should be below oracle"); + assert.ok(ask > 0n && ask > oracle, "ask should be above oracle"); + }); + + it("should show positive collateral via API", async () => { + const h = await fetchHealth(mm.port); + assert.ok(BigInt((h.inventory as Record).collateralBalance as string) > 0n); + }); + + it("should update inventory when a taker fills the ask", async () => { + const hBefore = await fetchHealth(mm.port); + assert.equal((hBefore.inventory as Record).netPosition, "0"); + + const bestAsk = BigInt((hBefore.market as Record).bestAsk as string); + const publicClient = createTestPublicClient(); + const takerWallet = createTestWalletClient(TAKER_ACCOUNT.privateKey); + const perps = getContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + client: { public: publicClient, wallet: takerWallet }, + }); + + await perps.write.createOrder([bestAsk, parseUnits("1", deployment.config.quantityDecimals)]); + + // Wait for MM to detect the fill + let hAfter!: Record; + await waitFor(async () => { + hAfter = await fetchHealth(mm.port); + return (hAfter.inventory as Record).netPosition !== "0"; + }, 15_000); + + const inv = hAfter.inventory as Record; + assert.ok(BigInt(inv.netPosition as string) < 0n, "MM should be short"); + assert.ok((inv.inventorySkew as number) < 0, "skew should be negative"); + }); + + it("should requote when oracle price changes", async () => { + const hBefore = await fetchHealth(mm.port); + const bestBidBefore = BigInt((hBefore.market as Record).bestBid as string); + + const ownerWallet = createTestWalletClient(OWNER_ACCOUNT.privateKey); + const oracle = getContract({ + address: deployment.contracts.oracleAddress, + abi: priceOracleMockAbi, + client: { wallet: ownerWallet }, + }); + await oracle.write.setPrice([ + deployment.config.oracle.price * 2n, + deployment.config.oracle.decimals, + ]); + + // Wait for the book to reflect the higher bid + let hAfter!: Record; + await waitFor(async () => { + hAfter = await fetchHealth(mm.port); + return BigInt((hAfter.market as Record).bestBid as string) > bestBidBefore; + }, 15_000); + + assert.ok(BigInt((hAfter.market as Record).bestBid as string) > bestBidBefore, "bid should move up"); + }); +}); + +// ── Test group 2: on-chain book structure ─────────────────────────────────── + +describe("MM process — on-chain book structure", () => { + let mm: MakerProcess; + const port = HEALTH_PORT + 2; + let publicClient: ReturnType; + + before(async () => { + await revertToBase(); + mm = spawnMM(port); + await waitForReady(mm); + publicClient = createTestPublicClient(); + }); + + after(async () => { + await stopMM(mm); + }); + + it("should place exactly numLevelsPerSide bids and asks on-chain", async () => { + const orderIds = await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + }); + // 3 levels per side = 6 total + assert.equal((orderIds as unknown[]).length, 6); + }); + + it("should have every order price tick-aligned", async () => { + const tick = await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "minimumPriceIncrement", + }); + const orderIds = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + })) as `0x${string}`[]; + + const orderCalls = orderIds.map((id) => ({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + const results = await publicClient.multicall({ contracts: orderCalls }); + + for (const r of results) { + assert.equal(r.status, "success"); + const order = r.result as { price: bigint }; + assert.equal(order.price % (tick as bigint), 0n, `price ${order.price} not tick-aligned`); + } + }); + + it("should separate into positive-qty bids and negative-qty asks", async () => { + const orderIds = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + })) as `0x${string}`[]; + + const orderCalls = orderIds.map((id) => ({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + const results = await publicClient.multicall({ contracts: orderCalls }); + + let bids = 0; + let asks = 0; + for (const r of results) { + const order = r.result as { quantity: bigint }; + if (order.quantity > 0n) bids++; + else if (order.quantity < 0n) asks++; + } + assert.equal(bids, 3, "should have 3 bids"); + assert.equal(asks, 3, "should have 3 asks"); + }); + + it("should have increasing order size at deeper levels", async () => { + const orderIds = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + })) as `0x${string}`[]; + + const orderCalls = orderIds.map((id) => ({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + const results = await publicClient.multicall({ contracts: orderCalls }); + + const bids: { price: bigint; qty: bigint }[] = []; + const asks: { price: bigint; qty: bigint }[] = []; + for (const r of results) { + const o = r.result as { price: bigint; quantity: bigint }; + if (o.quantity > 0n) bids.push({ price: o.price, qty: o.quantity }); + else asks.push({ price: o.price, qty: -o.quantity }); + } + // Sort bids descending by price (best bid first) + bids.sort((a, b) => (a.price > b.price ? -1 : 1)); + // Sort asks ascending by price (best ask first) + asks.sort((a, b) => (a.price < b.price ? -1 : 1)); + + for (let i = 1; i < bids.length; i++) { + assert.ok(bids[i].qty > bids[i - 1].qty, "deeper bid should have larger size"); + assert.ok(bids[i].price < bids[i - 1].price, "deeper bid should have lower price"); + } + for (let i = 1; i < asks.length; i++) { + assert.ok(asks[i].qty > asks[i - 1].qty, "deeper ask should have larger size"); + assert.ok(asks[i].price > asks[i - 1].price, "deeper ask should have higher price"); + } + }); + + it("should maintain at least minSpreadBps between best bid and ask", async () => { + const [bestBid, bestAsk, oraclePrice] = await Promise.all([ + publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getBestBidPrice", + }) as Promise, + publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getBestAskPrice", + }) as Promise, + publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getMarketPrice", + }) as Promise, + ]); + + assert.ok(bestBid > 0n && bestAsk > 0n); + const spreadBps = ((bestAsk - bestBid) * 10000n) / oraclePrice; + assert.ok(spreadBps >= 50n, `spread ${spreadBps}bps should be >= 50bps (minSpreadBps)`); + }); + + it("should have on-chain best bid/ask matching health API", async () => { + const [onChainBid, onChainAsk] = await Promise.all([ + publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getBestBidPrice", + }) as Promise, + publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getBestAskPrice", + }) as Promise, + ]); + + const h = await fetchHealth(port); + const market = h.market as Record; + assert.equal(BigInt(market.bestBid as string), onChainBid, "bestBid should match"); + assert.equal(BigInt(market.bestAsk as string), onChainAsk, "bestAsk should match"); + }); + + it("should show MM depth in getQuantityAtPrice for each book level", async () => { + const [bidPrices, askPrices] = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getOrderBookPrices", + args: [200n], + })) as [bigint[], bigint[]]; + + assert.ok(bidPrices.length >= 3, "should have at least 3 bid price levels"); + assert.ok(askPrices.length >= 3, "should have at least 3 ask price levels"); + + const depthCalls = [ + ...bidPrices.slice(0, 3).map((p) => ({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, true] as const, + })), + ...askPrices.slice(0, 3).map((p) => ({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, false] as const, + })), + ]; + const results = await publicClient.multicall({ contracts: depthCalls }); + + for (let i = 0; i < 6; i++) { + assert.equal(results[i].status, "success"); + assert.ok((results[i].result as bigint) > 0n, `level ${i} should have non-zero depth`); + } + }); + + it("should match on-chain collateral balance with health API", async () => { + const balance = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "balanceOf", + args: [MM_ACCOUNT.address], + })) as bigint; + + const h = await fetchHealth(port); + assert.equal(BigInt((h.inventory as Record).collateralBalance as string), balance); + }); + + it("should allow taker to simulate matching against MM orders", async () => { + const h = await fetchHealth(port); + const bestAsk = BigInt((h.market as Record).bestAsk as string); + const qty = parseUnits("1", deployment.config.quantityDecimals); + + const result = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "simulateOrder", + args: [bestAsk, qty], + })) as [bigint, bigint, bigint]; + + const [filledQty, avgPrice, remainingQty] = result; + assert.ok(filledQty > 0n, "should fill some quantity"); + assert.ok(avgPrice > 0n, "should have a fill price"); + assert.equal(remainingQty, 0n, "1-unit order should be fully filled"); + }); + + it("should have zero position before any fills", async () => { + const pos = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserPosition", + args: [MM_ACCOUNT.address], + })) as { netQuantity: bigint; aggregatedEntryPrice: bigint }; + + assert.equal(pos.netQuantity, 0n, "no position before fills"); + }); +}); + +// ── Test group 3: post-fill on-chain state (cumulative) ───────────────────── + +describe("MM process — post-fill on-chain state", () => { + let mm: MakerProcess; + const port = HEALTH_PORT + 3; + let publicClient: ReturnType; + + before(async () => { + await revertToBase(); + mm = spawnMM(port); + await waitForReady(mm); + publicClient = createTestPublicClient(); + }); + + after(async () => { + await stopMM(mm); + }); + + it("should create short position on-chain when taker fills the ask", async () => { + const h = await fetchHealth(port); + const bestAsk = BigInt((h.market as Record).bestAsk as string); + const qty = parseUnits("1", deployment.config.quantityDecimals); + + const takerWallet = createTestWalletClient(TAKER_ACCOUNT.privateKey); + const perps = getContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + client: { public: publicClient, wallet: takerWallet }, + }); + await perps.write.createOrder([bestAsk, qty]); + + const pos = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserPosition", + args: [MM_ACCOUNT.address], + })) as { netQuantity: bigint; aggregatedEntryPrice: bigint }; + + assert.ok(pos.netQuantity < 0n, `MM should be short, got ${pos.netQuantity}`); + assert.ok(pos.aggregatedEntryPrice > 0n, "entry price should be set"); + }); + + it("should have non-zero required margin after position opens", async () => { + const reqMargin = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getMaintenanceMargin", + args: [MM_ACCOUNT.address], + })) as bigint; + + assert.ok(reqMargin > 0n, "required margin should be positive with open position"); + }); + + it("should still maintain resting orders after the fill", async () => { + await waitFor(async () => { + const h = await fetchHealth(port); + return ((h.market as Record).ownOrders as number) >= 5; + }, 15_000); + + const orderIds = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + })) as `0x${string}`[]; + + assert.ok( + orderIds.length >= 5, + `should have at least 5 orders after fill, got ${orderIds.length}`, + ); + }); + + it("should show negative unrealized PnL when price rises against short", async () => { + const ownerWallet = createTestWalletClient(OWNER_ACCOUNT.privateKey); + const oracle = getContract({ + address: deployment.contracts.oracleAddress, + abi: priceOracleMockAbi, + client: { wallet: ownerWallet }, + }); + await oracle.write.setPrice([ + deployment.config.oracle.price * 3n, + deployment.config.oracle.decimals, + ]); + + const pnl = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUnrealizedPnl", + args: [MM_ACCOUNT.address], + })) as bigint; + + assert.ok(pnl < 0n, `short + rising price should produce negative PnL, got ${pnl}`); + }); + + it("should reduce short when taker fills the bid", async () => { + // Wait for MM to requote with the new oracle price + await waitFor(async () => { + const h = await fetchHealth(port); + const m = h.market as Record; + const s = h.stats as Record; + return BigInt(m.bestBid as string) > 0n && (s.reconcileCount as number) > 1; + }, 15_000); + + const posBefore = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserPosition", + args: [MM_ACCOUNT.address], + })) as { netQuantity: bigint }; + const netBefore = posBefore.netQuantity; + + const h = await fetchHealth(port); + const bestBid = BigInt((h.market as Record).bestBid as string); + const qty = parseUnits("1", deployment.config.quantityDecimals); + + const takerWallet = createTestWalletClient(TAKER_ACCOUNT.privateKey); + const perps = getContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + client: { public: publicClient, wallet: takerWallet }, + }); + // Taker sells into MM's bid + await perps.write.createOrder([bestBid, -qty]); + + const posAfter = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserPosition", + args: [MM_ACCOUNT.address], + })) as { netQuantity: bigint }; + + assert.ok( + posAfter.netQuantity > netBefore, + `position should reduce toward zero: ${netBefore} → ${posAfter.netQuantity}`, + ); + }); + + it("should not be liquidatable with sufficient collateral", async () => { + const isLiquidatable = (await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "isLiquidatable", + args: [MM_ACCOUNT.address], + })) as boolean; + + assert.equal(isLiquidatable, false, "well-collateralized MM should not be liquidatable"); + }); +}); + +// ── Test group 4: graceful shutdown (clean state) ─────────────────────────── + +describe("MM process — graceful shutdown", () => { + let mm: MakerProcess; + + before(async () => { + await revertToBase(); + mm = spawnMM(HEALTH_PORT + 4); + await waitForReady(mm); + }); + + after(async () => { + await stopMM(mm); + }); + + it("should cancel all orders on SIGTERM", async () => { + const hBefore = await fetchHealth(mm.port); + assert.ok(((hBefore.market as Record).ownOrders as number) > 0, "should have orders before shutdown"); + + mm.child.kill("SIGTERM"); + const exitCode = await Promise.race([mm.exited, sleep(10_000).then(() => null)]); + assert.ok(exitCode === 0 || exitCode === null, `should exit cleanly, got ${exitCode}`); + + // Verify on-chain + const publicClient = createTestPublicClient(); + const orders = await publicClient.readContract({ + address: deployment.contracts.perpsAddress, + abi: hashPowerPerpsDexAbi, + functionName: "getUserOrders", + args: [MM_ACCOUNT.address], + }); + assert.equal((orders as unknown[]).length, 0, "all orders cancelled after SIGTERM"); + }); +}); diff --git a/market-maker/tests-pending/orderExecutor.test.ts b/market-maker/tests-pending/orderExecutor.test.ts new file mode 100644 index 0000000..40aac6a --- /dev/null +++ b/market-maker/tests-pending/orderExecutor.test.ts @@ -0,0 +1,677 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { decodeFunctionData } from "viem"; +import { OrderExecutor } from "../src/orderExecutor.ts"; +import { hashPowerPerpsDexAbi } from "../src/abi.ts"; +import type { MakerConfig } from "../src/config.ts"; +import type { Quoter, DesiredQuotes } from "../src/quoter.ts"; +import type { BookTracker, OwnOrder } from "../src/bookTracker.ts"; +import type { GasTracker } from "../src/gasTracker.ts"; +import type { RiskManager } from "../src/riskManager.ts"; +import type { OracleTracker } from "../src/oracleTracker.ts"; + +function makeConfig(overrides: Partial = {}): MakerConfig { + return { + network: "hardhat", + ethNodeAddress: "http://localhost:8545", + perpsAddress: "0x0000000000000000000000000000000000000001", + makerPrivateKey: "0x0000000000000000000000000000000000000000000000000000000000000001", + numLevelsPerSide: 3, + baseQuantity: 1_000_000n, + minSpreadBps: 10, + requoteCooldownMs: 0, + requoteThresholdTicks: 2, + urgentRequoteThresholdTicks: 10, + dryRun: false, + ...overrides, + } as MakerConfig; +} + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +function makeOrderId(n: number): `0x${string}` { + return `0x${n.toString(16).padStart(64, "0")}` as `0x${string}`; +} + +interface TestDeps { + config: MakerConfig; + quoter: Quoter; + book: BookTracker; + gas: GasTracker; + risk: RiskManager; + oracle: OracleTracker; + txHashes: string[]; + cancelledOrders: unknown[]; + placedOrders: unknown[]; +} + +function makeDeps(overrides: Partial = {}): TestDeps { + const txHashes: string[] = []; + const cancelledOrders: unknown[] = []; + const placedOrders: unknown[] = []; + + return { + config: makeConfig(), + quoter: { + getTick: () => 10_000n, + } as unknown as Quoter, + book: { + ownOrders: new Map<`0x${string}`, OwnOrder>(), + } as unknown as BookTracker, + gas: { + isGasSpiking: false, + gasSpikePct: 0, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as GasTracker, + risk: { + throttled: false, + recordGasCost: noop, + } as unknown as RiskManager, + oracle: { + currentPrice: 100_000_000n, + } as unknown as OracleTracker, + txHashes, + cancelledOrders, + placedOrders, + ...overrides, + }; +} + +function makeExecutor(deps: TestDeps): OrderExecutor { + const mockPublicClient = { + waitForTransactionReceipt: async () => ({ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }), + }; + + const mockWalletClient = { + writeContract: async (args: { functionName: string; args: unknown[] }) => { + if (args.functionName === "multicall") { + const calls = args.args[0] as `0x${string}`[]; + for (const callData of calls) { + const decoded = decodeFunctionData({ abi: hashPowerPerpsDexAbi, data: callData }); + if (decoded.functionName === "cancelOrder") deps.cancelledOrders.push(decoded.args[0]); + if (decoded.functionName === "createOrder") deps.placedOrders.push(decoded.args); + } + } + deps.txHashes.push("0xabc"); + return "0xabc" as `0x${string}`; + }, + }; + + const mockAccount = { address: "0x1234" as `0x${string}` }; + const mockChain = { id: 31337 }; + + return new OrderExecutor( + mockPublicClient as never, + mockWalletClient as never, + mockAccount as never, + mockChain as never, + deps.config, + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); +} + +describe("OrderExecutor", () => { + it("places new orders when no existing orders", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }; + + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 2); + assert.equal(deps.cancelledOrders.length, 0); + }); + + it("cancels stale orders and places new ones", async () => { + const deps = makeDeps(); + const staleId = makeOrderId(1); + deps.book.ownOrders.set(staleId, { orderId: staleId, price: 95_000_000n, quantity: 1_000_000n }); + + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await executor.reconcile(desired); + assert.equal(deps.cancelledOrders.length, 1); + assert.equal(deps.cancelledOrders[0], staleId); + assert.equal(deps.placedOrders.length, 1); + }); + + it("skips when existing orders match desired prices", async () => { + const deps = makeDeps(); + const id = makeOrderId(2); + deps.book.ownOrders.set(id, { orderId: id, price: 99_000_000n, quantity: 1_000_000n }); + + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await executor.reconcile(desired); + assert.equal(deps.cancelledOrders.length, 0); + assert.equal(deps.placedOrders.length, 0); + }); + + it("rethrows when multicall fails so tick can set health.lastError", async () => { + const deps = makeDeps(); + const multicallError = new Error("The contract function \"multicall\" reverted.\n\nError: FailedCall()"); + const mockWalletThrowing = { + writeContract: async (_args: { functionName: string }) => { + throw multicallError; + }, + }; + const executor = new OrderExecutor( + { waitForTransactionReceipt: async () => ({ gasUsed: 0n, effectiveGasPrice: 0n }) } as never, + mockWalletThrowing as never, + { address: "0x1234" as `0x${string}` } as never, + { id: 31337 } as never, + deps.config, + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await assert.rejects(() => executor.reconcile(desired), { message: /multicall.*reverted|FailedCall/ }); + }); + + it("skips reconcile during cooldown", async () => { + const deps = makeDeps({ config: makeConfig({ requoteCooldownMs: 999_999 }) }); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 1); + + deps.placedOrders.length = 0; + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 0, "should skip due to cooldown"); + }); + + it("skips reconcile during gas spike when drift is below urgent threshold", async () => { + const deps = makeDeps({ + gas: { + isGasSpiking: true, + gasSpikePct: 300, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as GasTracker, + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 1); + + deps.placedOrders.length = 0; + await executor.reconcile({ + bids: [{ price: 99_500_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 0, "should skip gas spike with small drift"); + }); + + it("proceeds with reconcile during gas spike when drift exceeds urgent threshold", async () => { + const deps = makeDeps({ + gas: { + isGasSpiking: true, + gasSpikePct: 300, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as GasTracker, + config: makeConfig({ urgentRequoteThresholdTicks: 1 }), + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + + deps.oracle.currentPrice = 200_000_000n; + deps.placedOrders.length = 0; + await executor.reconcile({ + bids: [{ price: 199_000_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.ok(deps.placedOrders.length > 0, "should proceed despite gas spike due to large drift"); + }); + + it("uses dry run mode: logs but does not submit", async () => { + const deps = makeDeps({ config: makeConfig({ dryRun: true }) }); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }; + + await executor.reconcile(desired); + assert.equal(deps.txHashes.length, 0, "dry run should not submit tx"); + }); + + it("cancelAll cancels all own orders", async () => { + const deps = makeDeps(); + const id1 = makeOrderId(10); + const id2 = makeOrderId(11); + deps.book.ownOrders.set(id1, { orderId: id1, price: 100n, quantity: 10n }); + deps.book.ownOrders.set(id2, { orderId: id2, price: 200n, quantity: 20n }); + + const executor = makeExecutor(deps); + await executor.cancelAll(); + + assert.equal(deps.cancelledOrders.length, 2); + }); + + it("cancelAll does nothing when no orders exist", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + await executor.cancelAll(); + assert.equal(deps.cancelledOrders.length, 0); + }); + + it("rethrows on cancelOrder failure so tick can set health.lastError", async () => { + const deps = makeDeps(); + const id = makeOrderId(20); + deps.book.ownOrders.set(id, { orderId: id, price: 100n, quantity: 10n }); + + const mockPublicClient = { + waitForTransactionReceipt: async () => ({ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }), + }; + const mockWalletClient = { + writeContract: async () => { + throw new Error("revert"); + }, + }; + + const executor = new OrderExecutor( + mockPublicClient as never, + mockWalletClient as never, + { address: "0x1234" as `0x${string}` } as never, + { id: 31337 } as never, + deps.config, + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); + + const desired: DesiredQuotes = { bids: [], asks: [] }; + await assert.rejects(() => executor.reconcile(desired), { message: "revert" }); + }); + + it("rethrows on placeOrder failure so tick can set health.lastError", async () => { + const deps = makeDeps(); + const mockPublicClient = { + waitForTransactionReceipt: async () => ({ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }), + }; + const mockWalletClient = { + writeContract: async () => { + throw new Error("out of gas"); + }, + }; + + const executor = new OrderExecutor( + mockPublicClient as never, + mockWalletClient as never, + { address: "0x1234" as `0x${string}` } as never, + { id: 31337 } as never, + deps.config, + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + await assert.rejects(() => executor.reconcile(desired), { message: "out of gas" }); + }); + + it("increases cooldown when risk is throttled", async () => { + const deps = makeDeps({ + config: makeConfig({ requoteCooldownMs: 1000, requoteThresholdTicks: 2 }), + risk: { throttled: true, recordGasCost: noop } as unknown as RiskManager, + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 1); + + deps.placedOrders.length = 0; + await executor.reconcile({ + bids: [{ price: 99_010_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 0, "throttled should increase cooldown"); + }); + + it("increases requote threshold when risk is throttled", async () => { + const deps = makeDeps({ + config: makeConfig({ requoteCooldownMs: 0, requoteThresholdTicks: 5 }), + risk: { throttled: true, recordGasCost: noop } as unknown as RiskManager, + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + + // Simulate that first reconcile placed an order at this price + const fakeId = makeOrderId(100); + deps.book.ownOrders.set(fakeId, { orderId: fakeId, price: 99_000_000n, quantity: 1_000_000n }); + + deps.placedOrders.length = 0; + // Drift of ~6 ticks: 60_000 / 10_000 = 6 < threshold*2 = 10 + deps.oracle.currentPrice = 100_060_000n; + await executor.reconcile({ + bids: [{ price: 99_060_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 0, "throttled doubles threshold from 5 to 10; drift of 6 should be below"); + }); + + it("cancelAll in dry run mode logs instead of cancelling", async () => { + const deps = makeDeps({ config: makeConfig({ dryRun: true }) }); + const id = makeOrderId(30); + deps.book.ownOrders.set(id, { orderId: id, price: 100n, quantity: 10n }); + + const executor = makeExecutor(deps); + await executor.cancelAll(); + assert.equal(deps.txHashes.length, 0, "dry run should not submit cancel tx"); + }); + + it("requotes with only asks when no orders exist", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }); + assert.equal(deps.placedOrders.length, 1); + }); + + it("skips when no desired quotes and no existing orders", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + await executor.reconcile({ bids: [], asks: [] }); + assert.equal(deps.placedOrders.length, 0); + assert.equal(deps.cancelledOrders.length, 0); + }); + + it("requotes when lastQuoteMidPrice is 0 and own orders exist", async () => { + const deps = makeDeps(); + const existingId = makeOrderId(300); + deps.book.ownOrders.set(existingId, { orderId: existingId, price: 98_000_000n, quantity: 1_000_000n }); + + const executor = makeExecutor(deps); + + // First reconcile with existing orders → priceDriftTicks returns Infinity (lastQuoteMidPrice=0) + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + // Stale order cancelled, new one placed + assert.equal(deps.cancelledOrders.length, 1); + assert.equal(deps.placedOrders.length, 1); + }); + + it("handles tick=0 in price drift calculation", async () => { + const deps = makeDeps({ + quoter: { getTick: () => 0n } as unknown as Quoter, + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + assert.equal(deps.placedOrders.length, 1); + + // Simulate existing orders so shouldRequote reaches priceDriftTicks + const fakeId = makeOrderId(200); + deps.book.ownOrders.set(fakeId, { orderId: fakeId, price: 99_000_000n, quantity: 1_000_000n }); + + deps.placedOrders.length = 0; + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + // With tick=0, drift returns 0, which is below threshold=2, so no requote + assert.equal(deps.placedOrders.length, 0); + }); + + it("replaces filled orders even when oracle price has not drifted", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }; + + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 2, "initial placement"); + + // Simulate both orders resting on-chain + const bidId = makeOrderId(500); + const askId = makeOrderId(501); + deps.book.ownOrders.set(bidId, { orderId: bidId, price: 99_000_000n, quantity: 1_000_000n }); + deps.book.ownOrders.set(askId, { orderId: askId, price: 101_000_000n, quantity: -1_000_000n }); + + // Simulate the ask getting fully filled: BookTracker removes it from ownOrders + deps.book.ownOrders.delete(askId); + assert.equal(deps.book.ownOrders.size, 1); + + // Oracle price unchanged — without the fix this reconcile would be skipped + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 1, "should place the missing ask"); + assert.equal(deps.cancelledOrders.length, 0, "surviving bid is still at desired price"); + }); + + it("replaces multiple filled orders in a single reconcile", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [ + { price: 99_000_000n, quantity: 1_000_000n }, + { price: 98_000_000n, quantity: 2_000_000n }, + ], + asks: [ + { price: 101_000_000n, quantity: -1_000_000n }, + ], + }; + + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 3, "initial placement"); + + // Simulate all three orders resting + const id1 = makeOrderId(600); + const id2 = makeOrderId(601); + const id3 = makeOrderId(602); + deps.book.ownOrders.set(id1, { orderId: id1, price: 99_000_000n, quantity: 1_000_000n }); + deps.book.ownOrders.set(id2, { orderId: id2, price: 98_000_000n, quantity: 2_000_000n }); + deps.book.ownOrders.set(id3, { orderId: id3, price: 101_000_000n, quantity: -1_000_000n }); + + // Both bids filled + deps.book.ownOrders.delete(id1); + deps.book.ownOrders.delete(id2); + + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 2, "should place both missing bids"); + assert.equal(deps.cancelledOrders.length, 0, "surviving ask is still correct"); + }); + + it("tops up partially filled bid with deficit quantity", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }; + + await executor.reconcile(desired); + assert.equal(deps.placedOrders.length, 2, "initial placement"); + + // Simulate both orders resting, then bid gets partially filled (1M → 300K) + const bidId = makeOrderId(700); + const askId = makeOrderId(701); + deps.book.ownOrders.set(bidId, { orderId: bidId, price: 99_000_000n, quantity: 300_000n }); + deps.book.ownOrders.set(askId, { orderId: askId, price: 101_000_000n, quantity: -1_000_000n }); + + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 1, "should place top-up order"); + assert.equal(deps.cancelledOrders.length, 0, "should not cancel anything"); + const [topUpPrice, topUpQty] = deps.placedOrders[0] as [bigint, bigint]; + assert.equal(topUpPrice, 99_000_000n, "top-up at same price"); + assert.equal(topUpQty, 700_000n, "top-up for deficit quantity"); + }); + + it("tops up partially filled ask with deficit quantity", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [{ price: 101_000_000n, quantity: -1_000_000n }], + }; + + await executor.reconcile(desired); + + // Simulate ask partially filled (-1M → -400K) + const bidId = makeOrderId(710); + const askId = makeOrderId(711); + deps.book.ownOrders.set(bidId, { orderId: bidId, price: 99_000_000n, quantity: 1_000_000n }); + deps.book.ownOrders.set(askId, { orderId: askId, price: 101_000_000n, quantity: -400_000n }); + + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 1, "should place top-up order"); + assert.equal(deps.cancelledOrders.length, 0); + const [topUpPrice, topUpQty] = deps.placedOrders[0] as [bigint, bigint]; + assert.equal(topUpPrice, 101_000_000n); + assert.equal(topUpQty, -600_000n, "top-up for deficit (negative = sell)"); + }); + + it("skips top-up when existing quantity matches desired", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await executor.reconcile(desired); + + // Existing order at same price with full quantity + const bidId = makeOrderId(720); + deps.book.ownOrders.set(bidId, { orderId: bidId, price: 99_000_000n, quantity: 1_000_000n }); + + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 0, "no top-up needed"); + assert.equal(deps.cancelledOrders.length, 0); + }); + + it("tops up when multiple own orders at same price sum to less than desired", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + const desired: DesiredQuotes = { + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }; + + await executor.reconcile(desired); + + // Two orders at same price, summing to 600K < desired 1M + const id1 = makeOrderId(730); + const id2 = makeOrderId(731); + deps.book.ownOrders.set(id1, { orderId: id1, price: 99_000_000n, quantity: 200_000n }); + deps.book.ownOrders.set(id2, { orderId: id2, price: 99_000_000n, quantity: 400_000n }); + + deps.placedOrders.length = 0; + deps.cancelledOrders.length = 0; + await executor.reconcile(desired); + + assert.equal(deps.placedOrders.length, 1); + const [, topUpQty] = deps.placedOrders[0] as [bigint, bigint]; + assert.equal(topUpQty, 400_000n, "deficit = 1M - 600K = 400K"); + }); + + it("records gas costs after successful transactions", async () => { + const gasCosts: bigint[] = []; + const deps = makeDeps({ + risk: { + throttled: false, + recordGasCost: (cost: bigint) => gasCosts.push(cost), + } as unknown as RiskManager, + }); + const executor = makeExecutor(deps); + + await executor.reconcile({ + bids: [{ price: 99_000_000n, quantity: 1_000_000n }], + asks: [], + }); + + assert.ok(gasCosts.length > 0, "should have recorded gas costs"); + }); +}); diff --git a/market-maker/tests-pending/quoter.test.ts b/market-maker/tests-pending/quoter.test.ts new file mode 100644 index 0000000..c364b04 --- /dev/null +++ b/market-maker/tests-pending/quoter.test.ts @@ -0,0 +1,335 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { Quoter } from "../src/quoter.ts"; +import type { OracleTracker } from "../src/oracleTracker.ts"; +import type { GasTracker } from "../src/gasTracker.ts"; +import type { InventoryManager } from "../src/inventoryManager.ts"; +import type { RiskManager } from "../src/riskManager.ts"; +import type { MakerConfig } from "../src/config.ts"; + +function makeConfig(overrides: Partial = {}): MakerConfig { + return { + network: "hardhat", + ethNodeAddress: "http://localhost:8545", + perpsAddress: "0x0000000000000000000000000000000000000001", + makerPrivateKey: "0x0000000000000000000000000000000000000000000000000000000000000001", + numLevelsPerSide: 3, + baseQuantity: 1_000_000n, + minSpreadBps: 10, + volatilityMultiplier: 2.0, + inventorySkewGamma: 0.5, + maxSkewTicks: 20, + ethPriceFeedAddress: undefined, + gasSpikeThresholdPct: 200, + gasCapMultiplier: 2.0, + gasPenaltyBps: 5, + maxGasBudgetPerHourUsd: 50_000_000n, + maxGasBudgetPerDayUsd: 500_000_000n, + urgentRequoteThresholdTicks: 10, + maxPositionSize: 100_000_000n, + maxUtilizationPct: 80, + minCollateralBalance: 100_000_000n, + maxDailyLossUsd: 1_000_000_000n, + pollIntervalMs: 3000, + requoteThresholdTicks: 2, + requoteCooldownMs: 1000, + resyncIntervalMs: 60000, + dryRun: false, + healthPort: 3001, + logLevel: "silent", + ...overrides, + } as MakerConfig; +} + +function makeOracle(price: bigint, vol = 0): OracleTracker { + return { currentPrice: price, volatility: vol } as OracleTracker; +} + +function makeGas(overrides: Partial = {}): GasTracker { + return { + currentGasPrice: 0n, + medianGasPrice: 0, + gasSpikePct: 0, + isGasSpiking: false, + roundTripCostUsd: 0n, + ethPriceUsd: 0n, + ...overrides, + } as unknown as GasTracker; +} + +function makeInventory(overrides: Partial = {}): InventoryManager { + return { + netQuantity: 0n, + collateralBalance: 1_000_000_000n, + requiredMargin: 0n, + inventorySkew: 0, + availableMargin: 1_000_000_000n, + utilizationPct: 0, + ...overrides, + } as InventoryManager; +} + +function makeRisk(overrides: Partial<{ quoteBid: boolean; quoteAsk: boolean }> = {}): RiskManager { + return { + allowedSides: () => ({ quoteBid: true, quoteAsk: true, ...overrides }), + } as unknown as RiskManager; +} + +function makePublicClient(): unknown { + return { + readContract: async () => 10_000n, // minimumPriceIncrement = 0.01 USDC (6 decimals) + }; +} + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +describe("Quoter", () => { + it("produces symmetric quotes around oracle price with zero inventory", async () => { + const config = makeConfig({ numLevelsPerSide: 1, minSpreadBps: 100 }); + const oracle = makeOracle(100_000_000n); // $100 + const gas = makeGas(); + const inventory = makeInventory(); + const risk = makeRisk(); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, gas, inventory, risk, makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 1); + assert.equal(quotes.asks.length, 1); + + // Bid should be below oracle, ask above + assert.ok(quotes.bids[0].price < oracle.currentPrice, "bid should be below oracle"); + assert.ok(quotes.asks[0].price > oracle.currentPrice, "ask should be above oracle (absolute value)"); + + // Bid quantity positive, ask quantity negative + assert.ok(quotes.bids[0].quantity > 0n, "bid qty should be positive"); + assert.ok(quotes.asks[0].quantity < 0n, "ask qty should be negative"); + }); + + it("produces no quotes when oracle price is 0", async () => { + const config = makeConfig(); + const oracle = makeOracle(0n); + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 0); + assert.equal(quotes.asks.length, 0); + }); + + it("only quotes ask side when position is at max long", async () => { + const config = makeConfig({ numLevelsPerSide: 2 }); + const oracle = makeOracle(100_000_000n); + const risk = makeRisk({ quoteBid: false, quoteAsk: true }); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), makeInventory(), risk, makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 0); + assert.ok(quotes.asks.length > 0); + }); + + it("produces multiple levels with increasing size", async () => { + const config = makeConfig({ numLevelsPerSide: 3, baseQuantity: 1_000_000n }); + const oracle = makeOracle(100_000_000n); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 3); + assert.equal(quotes.asks.length, 3); + + // Sizes should increase: 1x, 2x, 3x + assert.equal(quotes.bids[0].quantity, 1_000_000n); + assert.equal(quotes.bids[1].quantity, 2_000_000n); + assert.equal(quotes.bids[2].quantity, 3_000_000n); + }); + + it("exposes tick after initialization", async () => { + const quoter = new Quoter( + makePublicClient() as never, + makeConfig(), makeOracle(100_000_000n), makeGas(), makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + assert.equal(quoter.getTick(), 10_000n); + }); + + it("widens spread based on gas floor when roundTripCostUsd is non-zero", async () => { + const config = makeConfig({ numLevelsPerSide: 1, minSpreadBps: 5 }); + const oracle = makeOracle(100_000_000n); + + // Gas cost high enough that gasFloorBps > minSpreadBps + // roundTripCost = 1_000_000 ($1), notional = $100 → floor = 1_000_000 * 10000 / 100_000_000 = 100 bps + const gasWithCost = makeGas({ roundTripCostUsd: 1_000_000n }); + const gasNoCost = makeGas({ roundTripCostUsd: 0n }); + + const quoterGas = new Quoter( + makePublicClient() as never, + config, oracle, gasWithCost, makeInventory(), makeRisk(), makeLogger(), + ); + await quoterGas.initialize(); + + const quoterNoGas = new Quoter( + makePublicClient() as never, + config, oracle, gasNoCost, makeInventory(), makeRisk(), makeLogger(), + ); + await quoterNoGas.initialize(); + + const gasQuotes = quoterGas.computeQuotes(); + const noGasQuotes = quoterNoGas.computeQuotes(); + + const gasSpread = gasQuotes.asks[0].price - gasQuotes.bids[0].price; + const noGasSpread = noGasQuotes.asks[0].price - noGasQuotes.bids[0].price; + + assert.ok(gasSpread > noGasSpread, "gas floor should widen spread beyond minSpreadBps"); + }); + + it("returns 0 gas floor when expected notional is 0", async () => { + const config = makeConfig({ numLevelsPerSide: 1, baseQuantity: 0n }); + const oracle = makeOracle(100_000_000n); + const gas = makeGas({ roundTripCostUsd: 1_000_000n }); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, gas, makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + + // Should not crash even with 0 baseQuantity (notional = 0) + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 1); + }); + + it("produces no quotes when tick is 0 but oracle is valid", async () => { + const config = makeConfig({ numLevelsPerSide: 1 }); + const oracle = makeOracle(100_000_000n); + const quoter = new Quoter( + { readContract: async () => 0n } as never, + config, oracle, makeGas(), makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + assert.equal(quoter.getTick(), 0n); + + const quotes = quoter.computeQuotes(); + assert.equal(quotes.bids.length, 0); + assert.equal(quotes.asks.length, 0); + }); + + it("clamps bid price to tick when bidRaw is negative", async () => { + // Very wide spread on a low oracle price causes bidRaw to go negative + const config = makeConfig({ numLevelsPerSide: 1, minSpreadBps: 9000 }); + const oracle = makeOracle(100_000n); // very low price + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.ok(quotes.bids[0].price > 0n, "bid price should clamp to tick, not be negative"); + }); + + it("handles negative gas spike pct gracefully (no penalty)", async () => { + const config = makeConfig({ numLevelsPerSide: 1, gasPenaltyBps: 10 }); + const oracle = makeOracle(100_000_000n); + const gas = makeGas({ gasSpikePct: -50 }); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, gas, makeInventory(), makeRisk(), makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.ok(quotes.bids.length > 0); + assert.ok(quotes.asks.length > 0); + }); + + it("applies inventory skew shifting quotes when long", async () => { + const config = makeConfig({ numLevelsPerSide: 1, minSpreadBps: 100, inventorySkewGamma: 1.0, maxSkewTicks: 50 }); + const oracle = makeOracle(100_000_000n); + const invNeutral = makeInventory({ inventorySkew: 0 }); + const invLong = makeInventory({ inventorySkew: 0.5 }); + + const quoterNeutral = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), invNeutral, makeRisk(), makeLogger(), + ); + await quoterNeutral.initialize(); + + const quoterLong = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), invLong, makeRisk(), makeLogger(), + ); + await quoterLong.initialize(); + + const neutralQuotes = quoterNeutral.computeQuotes(); + const longQuotes = quoterLong.computeQuotes(); + + // Long position skews quotes down → bids strictly lower + assert.ok(longQuotes.bids[0].price < neutralQuotes.bids[0].price, "long skew should push bids down"); + // Asks should also shift down or remain equal (rounding may absorb small shifts) + assert.ok(longQuotes.asks[0].price <= neutralQuotes.asks[0].price, "long skew should push asks down or equal"); + }); + + it("only quotes bid side when risk blocks asks", async () => { + const config = makeConfig({ numLevelsPerSide: 2 }); + const oracle = makeOracle(100_000_000n); + const risk = makeRisk({ quoteBid: true, quoteAsk: false }); + + const quoter = new Quoter( + makePublicClient() as never, + config, oracle, makeGas(), makeInventory(), risk, makeLogger(), + ); + await quoter.initialize(); + + const quotes = quoter.computeQuotes(); + assert.ok(quotes.bids.length > 0); + assert.equal(quotes.asks.length, 0); + }); + + it("widens spread when gas spike penalty is active", async () => { + const config = makeConfig({ numLevelsPerSide: 1, minSpreadBps: 10, gasPenaltyBps: 10 }); + const oracle = makeOracle(100_000_000n); + const gasNormal = makeGas({ gasSpikePct: 0 }); + const gasSpike = makeGas({ gasSpikePct: 300 }); + + const quoterNormal = new Quoter( + makePublicClient() as never, + config, oracle, gasNormal, makeInventory(), makeRisk(), makeLogger(), + ); + await quoterNormal.initialize(); + + const quoterSpike = new Quoter( + makePublicClient() as never, + config, oracle, gasSpike, makeInventory(), makeRisk(), makeLogger(), + ); + await quoterSpike.initialize(); + + const normalQuotes = quoterNormal.computeQuotes(); + const spikeQuotes = quoterSpike.computeQuotes(); + + const normalSpread = normalQuotes.asks[0].price - normalQuotes.bids[0].price; + const spikeSpread = spikeQuotes.asks[0].price - spikeQuotes.bids[0].price; + + assert.ok(spikeSpread > normalSpread, "spike should produce wider spread"); + }); +}); diff --git a/market-maker/tests/config.test.ts b/market-maker/tests/config.test.ts new file mode 100644 index 0000000..3bff9eb --- /dev/null +++ b/market-maker/tests/config.test.ts @@ -0,0 +1,198 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig, type MakerConfig } from "../src/config.ts"; + +function writeTmp(dir: string, name: string, content: string): string { + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +const VALID_YAML = ` +wallets: + default: + privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +network: + name: arbitrum + rpcUrl: "https://arb1.arbitrum.io/rpc" +venue: + kind: perps + wallet: default + address: "0x1234567890123456789012345678901234567890" +pricing: + strategy: effective-spread + minSpreadBps: 10 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 +sizing: + strategy: linear + baseQuantity: "1000000" + numLevelsPerSide: 5 +risk: + maxPositionSize: "50000000" + maxUtilizationPct: 80 + minCollateralBalance: "10000000" + maxDailyLossUsd: "500000000" +gas: + gasCapMultiplier: 2.0 +timing: + pollIntervalMs: 3000 +health: + port: 8080 +`; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mm-cfg-")); +}); + +afterEach(() => { + try { unlinkSync(join(tmpDir, "test.yml")); } catch { /* ignore */ } +}); + +describe("loadConfig", () => { + it("parses a valid YAML file", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadConfig({ path }); + assert.strictEqual(cfg.venue.kind, "perps"); + assert.strictEqual(cfg.network.name, "arbitrum"); + assert.strictEqual(cfg.pricing.minSpreadBps, 10); + assert.strictEqual(cfg.sizing.baseQuantity, "1000000"); + }); + + it("applies defaults for optional fields", () => { + // Remove the whole timing block to trigger defaults + const yaml = VALID_YAML.replace( + /^timing:\n pollIntervalMs: 3000\n/m, + "timing: {}\n", + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadConfig({ path }); + assert.strictEqual(cfg.timing.pollIntervalMs, 3000); // default + }); + + it("expands ${VAR} tokens from env", () => { + const yaml = ` +wallets: + default: + privateKey: \${TEST_PRIVATE_KEY} +network: + name: arbitrum + rpcUrl: \${TEST_RPC_URL} +venue: + kind: perps + wallet: default + address: "0x1234567890123456789012345678901234567890" +pricing: + strategy: effective-spread + minSpreadBps: 5 + volatilityMultiplier: 1.0 + inventorySkewGamma: 0.3 + maxSkewTicks: 10 +sizing: + strategy: linear + baseQuantity: "500000" + numLevelsPerSide: 3 +risk: + maxPositionSize: "10000000" + minCollateralBalance: "5000000" + maxDailyLossUsd: "100000000" +gas: + gasCapMultiplier: 1.5 +timing: {} +health: {} +`; + const path = writeTmp(tmpDir, "test.yml", yaml); + const env: NodeJS.ProcessEnv = { + TEST_PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + TEST_RPC_URL: "https://example.com/rpc", + }; + const cfg = loadConfig({ path, env }); + assert.strictEqual(cfg.wallets["default"].privateKey, env.TEST_PRIVATE_KEY); + assert.strictEqual(cfg.network.rpcUrl, "https://example.com/rpc"); + }); + + it("throws for missing env variable", () => { + const yaml = VALID_YAML.replace( + '"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', + "${MISSING_VAR}", + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws( + () => loadConfig({ path, env: {} }), + /MISSING_VAR/, + ); + }); + + it("supports ${VAR:-default} fallback syntax", () => { + const yaml = VALID_YAML.replace( + '"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', + '${ABSENT_KEY:-0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890}', + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadConfig({ path, env: {} }); + assert.strictEqual( + cfg.wallets["default"].privateKey, + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ); + }); + + it("throws when venue.wallet is not declared in wallets map", () => { + const yaml = VALID_YAML.replace("wallet: default", "wallet: undeclaredWallet"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadConfig({ path }), /undeclaredWallet/); + }); + + it("throws on invalid venue.kind", () => { + const yaml = VALID_YAML.replace("kind: perps", "kind: invalidkind"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadConfig({ path }), /Config validation failed/); + }); + + it("throws on invalid address format", () => { + const yaml = VALID_YAML.replace( + '"0x1234567890123456789012345678901234567890"', + '"not-an-address"', + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadConfig({ path }), /Config validation failed/); + }); + + it("throws when config file does not exist", () => { + assert.throws( + () => loadConfig({ path: "/nonexistent/path/config.yml" }), + /Failed to read config/, + ); + }); + + it("throws when no path provided and no MAKER_CONFIG env", () => { + assert.throws( + () => loadConfig({ env: {} }), // no MAKER_CONFIG in env, no argv flag + /No config path/, + ); + }); + + it("accepts reservation-price strategy with riskAversion", () => { + const yaml = VALID_YAML + .replace("strategy: effective-spread", "strategy: reservation-price") + .replace(" inventorySkewGamma: 0.5\n", " riskAversion: 0.2\n marginCallTimeSeconds: 3600\n"); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadConfig({ path }); + assert.strictEqual(cfg.pricing.strategy, "reservation-price"); + }); + + it("parses futures.yml-style config with geometric-taper sizing", () => { + const yaml = VALID_YAML + .replace("strategy: linear", "strategy: geometric-taper") + .replace(" numLevelsPerSide: 5\n", " numLevelsPerSide: 4\n taperRatio: 0.6\n"); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg: MakerConfig = loadConfig({ path }); + assert.strictEqual(cfg.sizing.strategy, "geometric-taper"); + assert.strictEqual(cfg.sizing.taperRatio, 0.6); + }); +}); diff --git a/market-maker/tests/errSerializer.test.ts b/market-maker/tests/errSerializer.test.ts new file mode 100644 index 0000000..0593e5f --- /dev/null +++ b/market-maker/tests/errSerializer.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { serializeError, toErrorInfo } from "../src/errSerializer.ts"; + +describe("serializeError", () => { + it("preserves message and stack of a simple Error", () => { + const err = new Error("short"); + err.stack = "Error: short\n at fn (test.ts:1:1)"; + const out = serializeError(err); + assert.equal(out.message, "short"); + assert.equal(out.stack, err.stack); + }); + + it("preserves long messages without truncation", () => { + const msg = "x".repeat(200); + const err = new Error(msg); + const out = serializeError(err); + assert.equal(out.message, msg); + }); + + it("preserves full stack without truncation", () => { + const err = new Error("boom"); + err.stack = "Error: boom\n" + " at fn (file.ts:1:1)\n".repeat(10); + const out = serializeError(err); + assert.equal(out.stack, err.stack); + }); + + it("includes cause via errWithCause", () => { + const inner = new Error("root cause"); + const outer = new Error("wrapper", { cause: inner }); + const out = serializeError(outer); + const cause = out.cause as Record; + assert.equal(cause.message, "root cause"); + assert.ok(cause.stack); + }); + + it("strips an `abi` field at any depth", () => { + const err = new Error("contract"); + (err as unknown as Record).abi = [{ name: "fake" }]; + (err as unknown as Record).inner = { abi: [{ name: "deep" }], ok: 1 }; + const out = serializeError(err); + assert.equal(out.abi, undefined); + const inner = out.inner as Record; + assert.equal(inner.abi, undefined); + assert.equal(inner.ok, 1); + }); + + it("handles non-Error and null gracefully", () => { + assert.deepEqual(serializeError("oops"), { raw: "oops" }); + assert.deepEqual(serializeError(null), { raw: null }); + }); +}); + +describe("toErrorInfo", () => { + it("wraps non-Error as { message }", () => { + assert.deepEqual(toErrorInfo("plain"), { message: "plain" }); + }); + it("delegates to serializeError for Errors", () => { + const out = toErrorInfo(new Error("boom")); + assert.equal(out.message, "boom"); + }); +}); diff --git a/market-maker/tests/gasTracker.test.ts b/market-maker/tests/gasTracker.test.ts new file mode 100644 index 0000000..1d27747 --- /dev/null +++ b/market-maker/tests/gasTracker.test.ts @@ -0,0 +1,141 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { PublicClient } from "viem"; +import { GasTracker, type GasTrackerConfig } from "../src/gasTracker.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +function makeConfig(overrides: Partial = {}): GasTrackerConfig { + return { + gasSpikeThresholdPct: 200, + gasCapMultiplier: 2.0, + ...overrides, + }; +} + +describe("GasTracker (defaults)", () => { + it("starts with zeros and default gas-unit estimates", () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + assert.equal(tracker.currentGasPrice, 0n); + assert.equal(tracker.medianGasPrice, 0n); + assert.equal(tracker.gasSpikePct.valueOf(), 0); + assert.equal(tracker.isGasSpiking, false); + assert.equal(tracker.ethPriceUsd, 0n); + assert.equal(tracker.estimatedCreateGas, 300_000n); + assert.equal(tracker.estimatedCancelGas, 100_000n); + }); +}); + +describe("GasTracker.update", () => { + it("reads gas price and detects spike vs median", async () => { + let i = 0; + const prices = [ + 1_000_000_000n, + 1_000_000_000n, + 1_000_000_000n, + 1_000_000_000n, + 5_000_000_000n, + ]; + const client = { getGasPrice: async () => prices[i++] } as PublicClient; + const tracker = new GasTracker(client, makeConfig(), makeLogger()); + for (let j = 0; j < prices.length; j++) await tracker.update(); + assert.equal(tracker.currentGasPrice, 5_000_000_000n); + assert.ok(tracker.gasSpikePct.valueOf() > 100, "should detect spike"); + assert.equal(tracker.isGasSpiking, true); + }); + + it("reports no spike when prices are stable", async () => { + const client = { getGasPrice: async () => 1_000_000_000n } as PublicClient; + const tracker = new GasTracker(client, makeConfig(), makeLogger()); + for (let i = 0; i < 10; i++) await tracker.update(); + assert.equal(tracker.isGasSpiking, false); + }); + + it("handles first sample (median = 0) gracefully", async () => { + let first = true; + const client = { + getGasPrice: async () => { + if (first) { + first = false; + return 0n; + } + return 1_000_000_000n; + }, + } as PublicClient; + const tracker = new GasTracker(client, makeConfig(), makeLogger()); + await tracker.update(); + assert.equal(tracker.gasSpikePct.valueOf(), 0); + }); +}); + +describe("GasTracker.calibrate", () => { + it("updates estimatedCreateGas on success", async () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + await tracker.calibrate(async () => 250_000n); + assert.equal(tracker.estimatedCreateGas, 250_000n); + }); + + it("keeps defaults when estimator throws", async () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + await tracker.calibrate(async () => { + throw new Error("no order"); + }); + assert.equal(tracker.estimatedCreateGas, 300_000n); + }); + + it("ignores zero return", async () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + await tracker.calibrate(async () => 0n); + assert.equal(tracker.estimatedCreateGas, 300_000n); + }); +}); + +describe("GasTracker cost calculations", () => { + it("returns 0 USD costs when ethPriceUsd is 0", () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + assert.equal(tracker.placeCostUsd, 0n); + assert.equal(tracker.cancelCostUsd, 0n); + assert.equal(tracker.roundTripCostUsd, 0n); + }); + + it("computes place/cancel/round-trip USD cost", () => { + const tracker = new GasTracker({} as never, makeConfig(), makeLogger()); + tracker.ethPriceUsd = 2_000_000_000n; // $2000 (6 decimals) + tracker.currentGasPrice = 1_000_000_000n; // 1 gwei + // 300k * 1e9 * 2e9 / 1e18 = 600_000 + assert.equal(tracker.placeCostUsd, 600_000n); + assert.equal(tracker.cancelCostUsd, 200_000n); + assert.equal(tracker.roundTripCostUsd, 800_000n); + assert.equal(tracker.requoteCycleCostUsd(10), 8_000_000n); + }); +}); + +describe("GasTracker.cappedGasPrice", () => { + it("uses cap when current < cap (median * multiplier)", () => { + const tracker = new GasTracker( + {} as never, + makeConfig({ gasCapMultiplier: 2.0 }), + makeLogger(), + ); + tracker.currentGasPrice = 1_000_000_000n; + tracker.medianGasPrice = 1_000_000_000n; + assert.equal(tracker.cappedGasPrice(), 2_000_000_000n); + }); + + it("never below current (cap raised to current to avoid base-fee underrun)", () => { + const tracker = new GasTracker({} as never, makeConfig({ gasCapMultiplier: 2.0 }), makeLogger()); + tracker.currentGasPrice = 10_000_000_000n; + tracker.medianGasPrice = 1_000_000_000n; + assert.equal(tracker.cappedGasPrice(), 10_000_000_000n); + }); + + it("returns current when median is 0", () => { + const tracker = new GasTracker({} as never, makeConfig({ gasCapMultiplier: 2.0 }), makeLogger()); + tracker.currentGasPrice = 1_000_000_000n; + tracker.medianGasPrice = 0n; + assert.equal(tracker.cappedGasPrice(), 1_000_000_000n); + }); +}); diff --git a/market-maker/tests/geometricTaper.test.ts b/market-maker/tests/geometricTaper.test.ts new file mode 100644 index 0000000..823fbf2 --- /dev/null +++ b/market-maker/tests/geometricTaper.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { geometricTaperSizes } from "../src/sizing/geometricTaper.ts"; + +/** + * geometricTaperSizes(totalQuantity, ratio, numLevels) distributes totalQuantity + * across numLevels so that level k has weight ratio^k / sum(ratio^0..ratio^(N-1)). + * Equivalent to the futures geometricTaperAllocations but operating on bigint + * quantities directly. + */ +describe("geometricTaperSizes", () => { + it("throws for numLevels < 1", () => + assert.throws(() => geometricTaperSizes(1000n, 0.5, 0), /numLevels must be >= 1/)); + + it("throws for ratio <= 0", () => + assert.throws(() => geometricTaperSizes(1000n, 0, 4), /ratio must be in/)); + + it("throws for ratio >= 1", () => + assert.throws(() => geometricTaperSizes(1000n, 1, 4), /ratio must be in/)); + + it("returns single element equal to total for numLevels=1", () => { + assert.deepStrictEqual(geometricTaperSizes(1000n, 0.5, 1), [1000n]); + }); + + it("returns numLevels elements", () => { + assert.strictEqual(geometricTaperSizes(1000n, 0.5, 5).length, 5); + }); + + it("sum of sizes does not exceed totalQuantity", () => { + for (const levels of [3, 5, 7]) { + for (const ratio of [0.3, 0.5, 0.7]) { + const sizes = geometricTaperSizes(100_000n, ratio, levels); + const total = sizes.reduce((a, b) => a + b, 0n); + assert.ok(total <= 100_000n, `sum ${total} > 100_000n (levels=${levels}, ratio=${ratio})`); + } + } + }); + + it("truncation loss is bounded by numLevels", () => { + const budget = 1_000_000n; + const levels = 5; + const sizes = geometricTaperSizes(budget, 0.6, levels); + const loss = budget - sizes.reduce((a, b) => a + b, 0n); + assert.ok(loss <= BigInt(levels), `loss ${loss} > ${levels}`); + }); + + it("sizes are non-negative", () => { + const sizes = geometricTaperSizes(1000n, 0.4, 6); + for (const s of sizes) assert.ok(s >= 0n); + }); + + it("sizes are decreasing for ratio < 1", () => { + const sizes = geometricTaperSizes(100_000n, 0.5, 5); + for (let i = 1; i < sizes.length; i++) { + assert.ok(sizes[i] <= sizes[i - 1], `sizes[${i}]=${sizes[i]} > sizes[${i - 1}]=${sizes[i - 1]}`); + } + }); + + it("ratio=0.5: first size is roughly double second size", () => { + const sizes = geometricTaperSizes(1_000_000n, 0.5, 5); + // w[0]/w[1] = 1/0.5 = 2 exactly; bigint floor might shift by 1 + const ratio = Number(sizes[0]) / Number(sizes[1]); + assert.ok(ratio > 1.9 && ratio < 2.1, `ratio=${ratio}`); + }); + + it("handles zero budget", () => { + assert.deepStrictEqual(geometricTaperSizes(0n, 0.5, 4), [0n, 0n, 0n, 0n]); + }); + + it("handles large budget", () => { + const sizes = geometricTaperSizes(1_000_000_000_000n, 0.6, 5); + assert.strictEqual(sizes.length, 5); + for (const s of sizes) assert.ok(s > 0n); + }); +}); diff --git a/market-maker/tests/helpers.test.ts b/market-maker/tests/helpers.test.ts new file mode 100644 index 0000000..911f5ae --- /dev/null +++ b/market-maker/tests/helpers.test.ts @@ -0,0 +1,167 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { calculateOrders, resampleHourlyClose, type PricedOrder } from "../src/helpers.ts"; + +const HOUR_MS = 60 * 60 * 1000; + +// ─── calculateOrders ────────────────────────────────────────────────────────── + +describe("calculateOrders", () => { + const sort = (orders: PricedOrder[]) => + [...orders].sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); + + const eq = (actual: PricedOrder[], expected: PricedOrder[]) => { + assert.deepStrictEqual(sort(actual), sort(expected)); + }; + + it("returns empty for both empty", () => eq(calculateOrders([], []), [])); + + it("returns modelled when current is empty", () => { + eq(calculateOrders([{ price: 100n, qty: 5n }, { price: 200n, qty: 3n }], []), [ + { price: 100n, qty: 5n }, + { price: 200n, qty: 3n }, + ]); + }); + + it("returns negated current when modelled is empty", () => { + eq(calculateOrders([], [{ price: 100n, qty: 5n }, { price: 200n, qty: 3n }]), [ + { price: 100n, qty: -5n }, + { price: 200n, qty: -3n }, + ]); + }); + + it("returns empty when modelled equals current", () => { + const orders = [{ price: 100n, qty: 5n }, { price: 200n, qty: 3n }]; + eq(calculateOrders(orders, orders), []); + }); + + it("positive diff when modelled > current", () => + eq(calculateOrders([{ price: 100n, qty: 10n }], [{ price: 100n, qty: 3n }]), [{ price: 100n, qty: 7n }])); + + it("negative diff when modelled < current", () => + eq(calculateOrders([{ price: 100n, qty: 3n }], [{ price: 100n, qty: 10n }]), [{ price: 100n, qty: -7n }])); + + it("handles non-overlapping price levels", () => + eq(calculateOrders([{ price: 100n, qty: 5n }], [{ price: 200n, qty: 3n }]), [ + { price: 100n, qty: 5n }, + { price: 200n, qty: -3n }, + ])); + + it("aggregates multiple orders at same price", () => { + eq(calculateOrders([{ price: 100n, qty: 3n }, { price: 100n, qty: 4n }], + [{ price: 100n, qty: 2n }, { price: 100n, qty: 1n }]), + [{ price: 100n, qty: 4n }]); + }); + + it("handles negative quantity (short)", () => + eq(calculateOrders([{ price: 100n, qty: -5n }], []), [{ price: 100n, qty: -5n }])); + + it("handles transition from short to long", () => + eq(calculateOrders([{ price: 100n, qty: 3n }], [{ price: 100n, qty: -2n }]), [{ price: 100n, qty: 5n }])); + + it("zero qty in modelled produces no order", () => + eq(calculateOrders([{ price: 100n, qty: 0n }], []), [])); + + it("returns orders sorted by price ascending", () => { + const result = calculateOrders( + [{ price: 300n, qty: 1n }, { price: 100n, qty: 2n }, { price: 200n, qty: 3n }], + [], + ); + assert.deepStrictEqual(result, [ + { price: 100n, qty: 2n }, + { price: 200n, qty: 3n }, + { price: 300n, qty: 1n }, + ]); + }); + + it("invariant: applying result to current yields modelled", () => { + const modelled = [{ price: 100n, qty: 10n }, { price: 150n, qty: -5n }, { price: 200n, qty: 3n }]; + const current = [{ price: 100n, qty: 7n }, { price: 200n, qty: 5n }, { price: 250n, qty: 2n }]; + const delta = calculateOrders(modelled, current); + + const applied = new Map(); + for (const o of [...current, ...delta]) { + applied.set(o.price, (applied.get(o.price) ?? 0n) + o.qty); + } + const expected = new Map(); + for (const o of modelled) { + expected.set(o.price, (expected.get(o.price) ?? 0n) + o.qty); + } + for (const [k, v] of applied) if (v === 0n) applied.delete(k); + for (const [k, v] of expected) if (v === 0n) expected.delete(k); + assert.deepStrictEqual(applied, expected); + }); + + it("handles very large bigint values", () => { + const L = 1000000000000000000000n; + eq(calculateOrders([{ price: L, qty: L }], [{ price: L, qty: L / 2n }]), [{ price: L, qty: L / 2n }]); + }); +}); + +// ─── resampleHourlyClose ───────────────────────────────────────────────────── + +describe("resampleHourlyClose", () => { + it("returns empty for empty input", () => assert.deepStrictEqual(resampleHourlyClose([]), [])); + + it("snaps single point to bucket start", () => { + const base = HOUR_MS * 100; + assert.deepStrictEqual(resampleHourlyClose([{ date: base + 30 * 60 * 1000, price: 100n }]), [ + { date: base, price: 100n }, + ]); + }); + + it("takes last price when multiple points in same bucket", () => { + const base = HOUR_MS * 100; + assert.deepStrictEqual( + resampleHourlyClose([ + { date: base + 10 * 60 * 1000, price: 100n }, + { date: base + 20 * 60 * 1000, price: 200n }, + { date: base + 50 * 60 * 1000, price: 300n }, + ]), + [{ date: base, price: 300n }], + ); + }); + + it("fills missing buckets with LOCF", () => { + const h0 = HOUR_MS * 100; + const h1 = h0 + HOUR_MS; + const h2 = h0 + HOUR_MS * 2; + const h3 = h0 + HOUR_MS * 3; + assert.deepStrictEqual( + resampleHourlyClose([ + { date: h0 + 30 * 60 * 1000, price: 100n }, + { date: h3 + 15 * 60 * 1000, price: 400n }, + ]), + [ + { date: h0, price: 100n }, + { date: h1, price: 100n }, + { date: h2, price: 100n }, + { date: h3, price: 400n }, + ], + ); + }); + + it("handles unsorted input", () => { + const h0 = HOUR_MS * 100; + const h1 = h0 + HOUR_MS; + assert.deepStrictEqual( + resampleHourlyClose([ + { date: h1 + 30 * 60 * 1000, price: 200n }, + { date: h0 + 15 * 60 * 1000, price: 100n }, + ]), + [{ date: h0, price: 100n }, { date: h1, price: 200n }], + ); + }); + + it("works with custom interval", () => { + const HALF = 30 * 60 * 1000; + const base = HALF * 100; + assert.deepStrictEqual( + resampleHourlyClose( + [{ date: base + 10 * 60 * 1000, price: 100n }, { date: base + HALF + 5 * 60 * 1000, price: 200n }], + HALF, + ), + [{ date: base, price: 100n }, { date: base + HALF, price: 200n }], + ); + }); +}); diff --git a/market-maker/tests/math.test.ts b/market-maker/tests/math.test.ts new file mode 100644 index 0000000..24be33e --- /dev/null +++ b/market-maker/tests/math.test.ts @@ -0,0 +1,132 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + BPS_SCALE, + RollingBudget, + RollingWindow, + applyBps, + bigAbs, + calculateNotional, + roundDownToTick, + roundToTick, + roundUpToTick, +} from "../src/math.ts"; + +describe("rounding to tick (bigint)", () => { + it("roundDownToTick aligned/unaligned", () => { + assert.equal(roundDownToTick(100n, 10n), 100n); + assert.equal(roundDownToTick(105n, 10n), 100n); + assert.equal(roundDownToTick(99n, 10n), 90n); + }); + it("roundUpToTick aligned/unaligned", () => { + assert.equal(roundUpToTick(100n, 10n), 100n); + assert.equal(roundUpToTick(101n, 10n), 110n); + assert.equal(roundUpToTick(91n, 10n), 100n); + }); + it("roundToTick ties up", () => { + assert.equal(roundToTick(105n, 10n), 110n); + assert.equal(roundToTick(104n, 10n), 100n); + assert.equal(roundToTick(106n, 10n), 110n); + }); +}); + +describe("calculateNotional", () => { + it("price * absQuantity / 1e6", () => { + assert.equal(calculateNotional(100_000_000n, 1_000_000n), 100_000_000n); + assert.equal(calculateNotional(50_000_000n, 500_000n), 25_000_000n); + }); + it("treats negative quantity as absolute", () => { + assert.equal(calculateNotional(100_000_000n, -1_000_000n), 100_000_000n); + }); +}); + +describe("applyBps", () => { + it("adds positive bps", () => { + assert.equal(applyBps(10_000n, 100n), 10_100n); + }); + it("subtracts negative bps", () => { + assert.equal(applyBps(10_000n, -100n), 9_900n); + }); + it("BPS_SCALE constant is 10000", () => { + assert.equal(BPS_SCALE, 10_000n); + }); +}); + +describe("RollingWindow (bigint samples, Fraction volatility)", () => { + it("constant prices → zero volatility", () => { + const w = new RollingWindow(10); + for (let i = 0; i < 5; i++) w.push(100n); + assert.equal(w.volatility().valueOf(), 0); + }); + it("varying prices → non-zero volatility", () => { + const w = new RollingWindow(10); + for (const p of [100n, 102n, 98n, 101n, 99n]) w.push(p); + assert.ok(w.volatility().valueOf() > 0); + }); + it("median (odd count)", () => { + const w = new RollingWindow(5); + w.push(5n); + w.push(1n); + w.push(3n); + assert.equal(w.median(), 3n); + }); + it("median (even count, integer floor of average)", () => { + const w = new RollingWindow(5); + for (const v of [1n, 3n, 5n, 7n]) w.push(v); + assert.equal(w.median(), 4n); + }); + it("respects max size and exposes latest", () => { + const w = new RollingWindow(3); + for (const v of [1n, 2n, 3n, 4n]) w.push(v); + assert.equal(w.length, 3); + assert.equal(w.latest(), 4n); + }); + it("returns 0 vol with < 3 samples", () => { + const w = new RollingWindow(10); + w.push(100n); + w.push(200n); + assert.equal(w.volatility().valueOf(), 0); + }); + it("skips log returns when sample is 0 and yields 0 vol", () => { + const w = new RollingWindow(10); + w.push(0n); + w.push(0n); + w.push(0n); + w.push(100n); + assert.equal(w.volatility().valueOf(), 0); + }); + it("median 0 for empty window, latest undefined", () => { + const w = new RollingWindow(5); + assert.equal(w.median(), 0n); + assert.equal(w.latest(), undefined); + }); +}); + +describe("RollingBudget", () => { + it("sums entries within window", () => { + const b = new RollingBudget(60_000); + b.add(100n); + b.add(200n); + assert.equal(b.total(), 300n); + }); + it("prunes expired entries", () => { + const b = new RollingBudget(10); + b.add(100n, 0); + b.add(200n, 5); + assert.equal(b.total(100), 0n); + }); + it("keeps recent and prunes old", () => { + const b = new RollingBudget(50); + b.add(100n, 0); + b.add(500n, 100); + assert.equal(b.total(120), 500n); + }); +}); + +describe("bigAbs", () => { + it("works for negative, positive, zero", () => { + assert.equal(bigAbs(-42n), 42n); + assert.equal(bigAbs(42n), 42n); + assert.equal(bigAbs(0n), 0n); + }); +}); diff --git a/market-maker/tests/oracleTracker.test.ts b/market-maker/tests/oracleTracker.test.ts new file mode 100644 index 0000000..abad051 --- /dev/null +++ b/market-maker/tests/oracleTracker.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { OracleTracker } from "../src/oracleTracker.ts"; +import type { InstrumentAdapter } from "../src/adapter.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +function makeInstrument(prices: bigint[]): InstrumentAdapter { + let i = 0; + return { + id: "test", + venue: {} as InstrumentAdapter["venue"], + getIndexPrice: async () => prices[Math.min(i++, prices.length - 1)], + getMinTick: async () => 1n, + getOwnOrders: async () => [], + getPosition: async () => ({ netQuantity: 0n, entryPrice: 0n }), + getContext: async () => ({}), + getOrderBookSnapshot: async () => ({ bids: [], asks: [] }), + buildCancelCalldata: () => "0x", + buildCreateCalldata: () => "0x", + estimateCreateGas: async () => 0n, + }; +} + +describe("OracleTracker", () => { + it("starts with zero price and zero volatility", () => { + const tracker = new OracleTracker(makeInstrument([0n]), makeLogger()); + assert.equal(tracker.currentPrice, 0n); + assert.equal(tracker.volatility.valueOf(), 0); + }); + + it("updates price from instrument", async () => { + const tracker = new OracleTracker(makeInstrument([100_000_000n]), makeLogger()); + await tracker.update(); + assert.equal(tracker.currentPrice, 100_000_000n); + }); + + it("computes non-zero volatility from varying samples", async () => { + const prices = [100_000_000n, 101_000_000n, 99_000_000n, 102_000_000n]; + const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + for (let i = 0; i < prices.length; i++) await tracker.update(); + assert.ok(tracker.volatility.valueOf() > 0, "expected positive vol"); + }); + + it("volatility is 0 with fewer than 3 samples", async () => { + const prices = [100_000_000n, 101_000_000n]; + const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + await tracker.update(); + await tracker.update(); + assert.equal(tracker.volatility.valueOf(), 0); + }); + + it("ignores zero/negative prices in the window", async () => { + const prices = [0n, 0n, 0n, 100_000_000n]; + const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + for (let i = 0; i < prices.length; i++) await tracker.update(); + assert.equal(tracker.volatility.valueOf(), 0); + }); +}); diff --git a/market-maker/tests/realizedVolatility.test.ts b/market-maker/tests/realizedVolatility.test.ts new file mode 100644 index 0000000..aa7486e --- /dev/null +++ b/market-maker/tests/realizedVolatility.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { realizedVolatility } from "../src/helpers.ts"; + +describe("realizedVolatility", () => { + it("returns 0 for empty array", () => + assert.deepStrictEqual(realizedVolatility([]), { sigmaPerStep: 0 })); + + it("returns 0 for single price point", () => + assert.deepStrictEqual(realizedVolatility([{ date: 1000, price: 100n }]), { sigmaPerStep: 0 })); + + it("returns NaN for two prices with sample variance (division by zero)", () => { + // 2 prices → 1 return → sample variance divides by (n-1) = 0 + const result = realizedVolatility([ + { date: 1000, price: 100n }, + { date: 2000, price: 100n }, + ]); + assert.ok(Number.isNaN(result.sigmaPerStep)); + }); + + it("returns 0 for two equal prices with population variance", () => { + const result = realizedVolatility( + [{ date: 1000, price: 100n }, { date: 2000, price: 100n }], + false, + ); + assert.strictEqual(result.sigmaPerStep, 0); + }); + + it("returns 0 for multiple equal prices", () => { + const result = realizedVolatility([ + { date: 1000, price: 50n }, + { date: 2000, price: 50n }, + { date: 3000, price: 50n }, + { date: 4000, price: 50n }, + ]); + assert.strictEqual(result.sigmaPerStep, 0); + }); + + it("computes non-zero volatility for varying prices", () => { + const result = realizedVolatility([ + { date: 1000, price: 100n }, + { date: 2000, price: 110n }, + { date: 3000, price: 100n }, + { date: 4000, price: 110n }, + ]); + assert.ok(result.sigmaPerStep > 0, `expected > 0, got ${result.sigmaPerStep}`); + }); + + it("sample variance is larger than population variance", () => { + const prices = [ + { date: 1000, price: 100n }, + { date: 2000, price: 120n }, + { date: 3000, price: 90n }, + { date: 4000, price: 110n }, + ]; + const sample = realizedVolatility(prices, true); + const population = realizedVolatility(prices, false); + assert.ok(sample.sigmaPerStep > population.sigmaPerStep); + }); + + it("sorts unsorted input by date", () => { + const sorted = realizedVolatility([ + { date: 1000, price: 100n }, + { date: 2000, price: 110n }, + { date: 3000, price: 105n }, + ]); + const unsorted = realizedVolatility([ + { date: 3000, price: 105n }, + { date: 1000, price: 100n }, + { date: 2000, price: 110n }, + ]); + // Within floating-point tolerance + assert.ok( + Math.abs(sorted.sigmaPerStep - unsorted.sigmaPerStep) < 1e-10, + `sorted=${sorted.sigmaPerStep}, unsorted=${unsorted.sigmaPerStep}`, + ); + }); + + it("defaults to sample variance when parameter omitted", () => { + const prices = [ + { date: 1000, price: 100n }, + { date: 2000, price: 120n }, + { date: 3000, price: 90n }, + ]; + const def = realizedVolatility(prices); + const explicit = realizedVolatility(prices, true); + assert.ok(Math.abs(def.sigmaPerStep - explicit.sigmaPerStep) < 1e-12); + }); + + it("throws for price <= 0", () => { + assert.throws(() => realizedVolatility([{ date: 1000, price: 0n }]), /Invalid p\.price/); + assert.throws(() => realizedVolatility([{ date: 1000, price: -1n }]), /Invalid p\.price/); + }); + + it("throws for invalid date (NaN)", () => + assert.throws(() => realizedVolatility([{ date: NaN, price: 100n }]), /Invalid p\.date/)); + + it("throws for date <= 0", () => { + assert.throws(() => realizedVolatility([{ date: 0, price: 100n }]), /Invalid p\.date/); + assert.throws(() => realizedVolatility([{ date: -1000, price: 100n }]), /Invalid p\.date/); + }); + + it("throws for Infinity date", () => + assert.throws(() => realizedVolatility([{ date: Infinity, price: 100n }]), /Invalid p\.date/)); + + it("handles large price values without overflow", () => { + const result = realizedVolatility([ + { date: 1000, price: 1_000_000_000_000_000_000n }, + { date: 2000, price: 1_100_000_000_000_000_000n }, + { date: 3000, price: 1_050_000_000_000_000_000n }, + ]); + assert.ok(Number.isFinite(result.sigmaPerStep)); + assert.ok(result.sigmaPerStep > 0); + }); +}); diff --git a/market-maker/tests/reservationPrice.test.ts b/market-maker/tests/reservationPrice.test.ts new file mode 100644 index 0000000..e2584e2 --- /dev/null +++ b/market-maker/tests/reservationPrice.test.ts @@ -0,0 +1,171 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +import { computeReservationMidQuote } from "../src/pricing/reservationPrice.ts"; +import type { ReservationPriceConfig } from "../src/pricing/reservationPrice.ts"; +import type { OracleTracker } from "../src/oracleTracker.ts"; +import type { GasTracker } from "../src/gasTracker.ts"; +import type { InventoryManager } from "../src/inventoryManager.ts"; +import type { InstrumentContext } from "../src/adapter.ts"; + +const TICK = 1_000n; // $0.001 in 6-decimal USDC + +function makeOracle(price: bigint, vol = new Fraction(0n)): OracleTracker { + return { currentPrice: price, volatility: vol } as unknown as OracleTracker; +} + +function makeGas(spikePct = new Fraction(0n), roundTripUsd = 0n): GasTracker { + return { gasSpikePct: spikePct, roundTripCostUsd: roundTripUsd } as unknown as GasTracker; +} + +function makeInventory(netQuantity = 0n): InventoryManager { + return { netQuantity, inventorySkew: new Fraction(0n) } as unknown as InventoryManager; +} + +const baseCfg: ReservationPriceConfig = { + riskAversion: 0.1, + marginCallTimeSeconds: 3600, + minSpreadBps: 10, + volatilityMultiplier: 1.0, + gasPenaltyBps: 5, +}; + +describe("computeReservationMidQuote", () => { + it("with zero inventory, bid < ask and both near oracle", () => { + const { bidMid, askMid } = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg: baseCfg, + tick: TICK, + }); + assert.ok(bidMid < askMid, `bid ${bidMid} should be < ask ${askMid}`); + assert.ok(bidMid > 0n); + }); + + it("long inventory shifts mid down (reservation price < oracle)", () => { + const oracle = 1_000_000_000n; + const cfg = { ...baseCfg, riskAversion: 1.0, marginCallTimeSeconds: 3600 }; + + const noInv = computeReservationMidQuote({ + oracle: makeOracle(oracle, new Fraction(1n, 100n)), // σ=0.01 + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg, + tick: TICK, + }); + const longInv = computeReservationMidQuote({ + oracle: makeOracle(oracle, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: makeInventory(1_000_000n), // positive net qty → should push mid down + context: {}, + cfg, + tick: TICK, + }); + assert.ok(longInv.bidMid <= noInv.bidMid, "long inventory should push bid mid down or equal"); + }); + + it("short inventory shifts mid up (reservation price > oracle)", () => { + const oracle = 1_000_000_000n; + const cfg = { ...baseCfg, riskAversion: 1.0, marginCallTimeSeconds: 3600 }; + + const noInv = computeReservationMidQuote({ + oracle: makeOracle(oracle, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg, + tick: TICK, + }); + const shortInv = computeReservationMidQuote({ + oracle: makeOracle(oracle, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: makeInventory(-1_000_000n), // negative net qty → should push mid up + context: {}, + cfg, + tick: TICK, + }); + assert.ok(shortInv.askMid >= noInv.askMid, "short inventory should push ask mid up or equal"); + }); + + it("uses deliveryDate from context when provided", () => { + const nowMs = Date.now(); + const futureDelivery = Math.floor(nowMs / 1000) + 7200; // 2 hours from now + const context: InstrumentContext = { deliveryDate: futureDelivery }; + + const { bidMid, askMid } = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context, + cfg: baseCfg, + tick: TICK, + nowMs, + }); + assert.ok(bidMid < askMid); + assert.ok(bidMid > 0n); + }); + + it("expired deliveryDate (T=0) produces no inventory adjustment", () => { + const nowMs = Date.now(); + const pastDelivery = Math.floor(nowMs / 1000) - 100; // already expired + const bigInventory = makeInventory(100_000_000n); + const cfg = { ...baseCfg, riskAversion: 10.0 }; + + const expired = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: bigInventory, + context: { deliveryDate: pastDelivery }, + cfg, + tick: TICK, + nowMs, + }); + const noDelivery = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg: { ...cfg, riskAversion: 0 }, + tick: TICK, + nowMs, + }); + // With T=0, adjustment = 0 regardless of inventory; reservation price = oracle + // so bid/ask should be symmetric around oracle + assert.ok(expired.bidMid > 0n); + assert.ok(expired.bidMid < expired.askMid); + }); + + it("bid and ask are aligned to tick", () => { + const { bidMid, askMid } = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg: baseCfg, + tick: TICK, + }); + assert.strictEqual(bidMid % TICK, 0n, `bid ${bidMid} not aligned to tick ${TICK}`); + assert.strictEqual(askMid % TICK, 0n, `ask ${askMid} not aligned to tick ${TICK}`); + }); + + it("higher volatility produces wider spread", () => { + const opts = { + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg: baseCfg, + tick: TICK, + }; + + const lowVol = computeReservationMidQuote({ ...opts, oracle: makeOracle(1_000_000_000n, new Fraction(1n, 1000n)) }); + const highVol = computeReservationMidQuote({ ...opts, oracle: makeOracle(1_000_000_000n, new Fraction(1n, 10n)) }); + + const spreadLow = lowVol.askMid - lowVol.bidMid; + const spreadHigh = highVol.askMid - highVol.bidMid; + assert.ok(spreadHigh >= spreadLow, `high-vol spread ${spreadHigh} should be >= low-vol spread ${spreadLow}`); + }); +}); diff --git a/market-maker/tests/riskManager.test.ts b/market-maker/tests/riskManager.test.ts new file mode 100644 index 0000000..a0aa728 --- /dev/null +++ b/market-maker/tests/riskManager.test.ts @@ -0,0 +1,181 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +import { RiskManager, type RiskManagerConfig } from "../src/riskManager.ts"; +import type { InventoryManager } from "../src/inventoryManager.ts"; +import type { GasTracker } from "../src/gasTracker.ts"; +import type { OracleTracker } from "../src/oracleTracker.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ info: noop, warn: noop, error: noop }) } as never; +} + +function makeConfig(overrides: Partial = {}): RiskManagerConfig { + return { + maxPositionSize: 100_000_000n, + maxUtilizationPct: 80, + minCollateralBalance: 100_000_000n, + maxDailyLossUsd: 500_000_000n, + maxGasBudgetPerHourUsd: 50_000_000n, + maxGasBudgetPerDayUsd: 500_000_000n, + ...overrides, + }; +} + +function makeInventory(overrides: Partial = {}): InventoryManager { + return { + netQuantity: 0n, + collateralBalance: 1_000_000_000n, + maintenanceMargin: 100_000_000n, + inventorySkew: new Fraction(0n), + availableMargin: 900_000_000n, + utilizationPct: 10, + ...overrides, + } as InventoryManager; +} + +const dummyGas = {} as GasTracker; +const dummyOracle = { currentPrice: 100_000_000n } as OracleTracker; + +describe("RiskManager", () => { + it("allows quoting when healthy", () => { + const r = new RiskManager(makeConfig(), makeInventory(), dummyGas, dummyOracle, makeLogger()); + r.initialize(); + assert.equal(r.check(), true); + assert.equal(r.halted, false); + }); + + it("halts when collateral drops below minimum", () => { + const inv = makeInventory({ collateralBalance: 50_000_000n }); + const r = new RiskManager(makeConfig({ minCollateralBalance: 100_000_000n }), inv, dummyGas, dummyOracle, makeLogger()); + r.initialize(); + assert.equal(r.check(), false); + assert.equal(r.halted, true); + assert.equal(r.haltReason?.message, "collateral below minimum"); + }); + + it("halts on daily loss exceeding limit", () => { + const inv = makeInventory({ collateralBalance: 400_000_000n }); + const r = new RiskManager( + makeConfig({ maxDailyLossUsd: 500_000_000n, minCollateralBalance: 0n }), + inv, + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + (r as unknown as Record).startOfDayBalance = 1_000_000_000n; + assert.equal(r.check(), false); + assert.equal(r.haltReason?.message, "daily loss limit breached"); + }); + + it("records gas costs into cumulative", () => { + const r = new RiskManager(makeConfig(), makeInventory(), dummyGas, dummyOracle, makeLogger()); + r.initialize(); + r.recordGasCost(10_000_000n); + r.recordGasCost(20_000_000n); + assert.equal(r.cumulativeGasCostUsd, 30_000_000n); + }); + + it("allowedSides: both when neutral and within caps", () => { + const r = new RiskManager(makeConfig(), makeInventory({ netQuantity: 0n }), dummyGas, dummyOracle, makeLogger()); + assert.deepEqual(r.allowedSides(), { quoteBid: true, quoteAsk: true }); + }); + + it("allowedSides: blocks bid at max long with high utilization", () => { + const r = new RiskManager( + makeConfig({ maxPositionSize: 100_000_000n, maxUtilizationPct: 80 }), + makeInventory({ netQuantity: 100_000_000n, utilizationPct: 90 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: true }); + }); + + it("allowedSides: blocks ask at max short with high utilization", () => { + const r = new RiskManager( + makeConfig({ maxPositionSize: 100_000_000n, maxUtilizationPct: 80 }), + makeInventory({ netQuantity: -100_000_000n, utilizationPct: 90 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + assert.deepEqual(r.allowedSides(), { quoteBid: true, quoteAsk: false }); + }); + + it("allowedSides: blocks both when utilization high and position zero", () => { + const r = new RiskManager( + makeConfig({ maxUtilizationPct: 80 }), + makeInventory({ netQuantity: 0n, utilizationPct: 90 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: false }); + }); + + it("throttles when hourly gas budget exceeded", () => { + const r = new RiskManager( + makeConfig({ maxGasBudgetPerHourUsd: 10_000_000n }), + makeInventory(), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + r.recordGasCost(15_000_000n); + r.check(); + assert.equal(r.throttled, true); + assert.equal(r.throttleReason, "gas_hourly"); + }); + + it("throttles when daily gas budget exceeded but hourly is fine", () => { + const r = new RiskManager( + makeConfig({ maxGasBudgetPerHourUsd: 1_000_000_000n, maxGasBudgetPerDayUsd: 10_000_000n }), + makeInventory(), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + r.recordGasCost(15_000_000n); + r.check(); + assert.equal(r.throttled, true); + assert.equal(r.throttleReason, "gas_daily"); + }); + + it("includes gas in daily PnL calculation", () => { + const inv = makeInventory({ collateralBalance: 900_000_000n }); + const r = new RiskManager( + makeConfig({ maxDailyLossUsd: 200_000_000n, minCollateralBalance: 0n }), + inv, + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + r.recordGasCost(250_000_000n); + assert.equal(r.check(), false); + assert.equal(r.haltReason?.message, "daily loss limit breached"); + }); + + it("resets PnL counters on day rollover", () => { + const r = new RiskManager( + makeConfig({ minCollateralBalance: 0n, maxDailyLossUsd: 1_000_000_000n }), + makeInventory({ collateralBalance: 1_000_000_000n }), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + r.recordGasCost(50_000_000n); + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + yesterday.setUTCHours(12, 0, 0, 0); + (r as unknown as Record).startOfDayTimestamp = yesterday.getTime(); + r.check(); + assert.equal(r.cumulativeGasCostUsd, 0n); + }); +}); diff --git a/market-maker/tsconfig.json b/market-maker/tsconfig.json new file mode 100644 index 0000000..5651bc8 --- /dev/null +++ b/market-maker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src", "tests"] +} From 1613216485c33fd8d126aa62b365a30829a0759a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 16:08:05 +0200 Subject: [PATCH 005/180] reimplementation of MM --- .github/workflows/market-maker-tests.yml | 67 + market-maker/.vscode/settings.json | 13 + market-maker/Dockerfile | 8 +- market-maker/README.md | 62 +- market-maker/configs/futures.dev.yml | 68 + market-maker/configs/futures.local.yml | 66 + market-maker/configs/futures.prd.yml | 68 + market-maker/configs/futures.stg.yml | 68 + market-maker/configs/futures.yml | 80 - market-maker/configs/perps.dev.yml | 69 + market-maker/configs/perps.local.yml | 70 + market-maker/configs/perps.prd.yml | 70 + market-maker/configs/perps.stg.yml | 69 + market-maker/configs/perps.yml | 67 - market-maker/docker-entrypoint.sh | 47 + market-maker/infra/README.md | 64 + market-maker/infra/ecs-task.tf | 148 ++ market-maker/package.json | 16 +- market-maker/schemas/futures.json | 508 ++++ market-maker/schemas/perps.json | 487 ++++ market-maker/scripts/gen-schemas.ts | 99 + market-maker/scripts/sync-abi.sh | 36 + market-maker/src/abi/CollateralVault.ts | 940 +++++++ market-maker/src/abi/Futures.ts | 1545 ++++++++++++ market-maker/src/abi/FuturesMmExtensions.ts | 70 + market-maker/src/abi/HashPowerPerpsDEX.ts | 1474 +++++++++++ market-maker/src/abi/Multicall3.ts | 440 ++++ market-maker/src/abi/PortfolioMarginEngine.ts | 588 +++++ market-maker/src/adapter.ts | 144 -- market-maker/src/adapters/futures/abi.ts | 221 -- market-maker/src/adapters/futures/events.ts | 87 + market-maker/src/adapters/futures/factory.ts | 508 ---- market-maker/src/adapters/futures/index.ts | 27 +- .../src/adapters/futures/instrument.ts | 216 ++ .../src/adapters/futures/ownOrders.ts | 133 + market-maker/src/adapters/futures/venue.ts | 242 ++ .../src/adapters/options-stub/index.ts | 77 - market-maker/src/adapters/perps/abi.ts | 2207 ----------------- market-maker/src/adapters/perps/collateral.ts | 117 - market-maker/src/adapters/perps/events.ts | 89 + market-maker/src/adapters/perps/factory.ts | 331 --- market-maker/src/adapters/perps/index.ts | 32 +- market-maker/src/adapters/perps/instrument.ts | 253 ++ market-maker/src/adapters/perps/venue.ts | 221 ++ market-maker/src/apps/futures/config.ts | 96 + market-maker/src/apps/futures/main.ts | 174 ++ market-maker/src/apps/perps/config.ts | 90 + market-maker/src/apps/perps/main.ts | 167 ++ market-maker/src/config.ts | 200 -- market-maker/src/core/adapter.ts | 270 ++ market-maker/src/{ => core}/bookTracker.ts | 76 +- market-maker/src/{ => core}/client.ts | 9 +- market-maker/src/core/collateralTracker.ts | 108 + market-maker/src/core/config/base.ts | 190 ++ market-maker/src/core/env.ts | 39 + market-maker/src/{ => core}/errSerializer.ts | 0 market-maker/src/{ => core}/errors.ts | 0 market-maker/src/{ => core}/gasTracker.ts | 8 +- market-maker/src/{ => core}/healthcheck.ts | 98 +- market-maker/src/{ => core}/helpers.ts | 48 +- market-maker/src/core/inventoryManager.ts | 67 + market-maker/src/{ => core}/math.ts | 38 +- market-maker/src/{ => core}/oracleTracker.ts | 0 market-maker/src/{ => core}/orderExecutor.ts | 152 +- .../src/{ => core}/pricing/effectiveSpread.ts | 83 +- .../src/core/pricing/reservationPrice.ts | 142 ++ market-maker/src/{ => core}/quoter.ts | 76 +- market-maker/src/{ => core}/rational.ts | 39 +- market-maker/src/{ => core}/riskManager.ts | 118 +- market-maker/src/core/runner.ts | 181 ++ .../src/core/sizing/geometricTaper.ts | 62 + market-maker/src/{ => core}/sizing/linear.ts | 7 +- market-maker/src/core/vaultDeposit.ts | 232 ++ market-maker/src/{ => core}/wallet.ts | 19 +- market-maker/src/index.ts | 36 - market-maker/src/inventoryManager.ts | 102 - market-maker/src/main.ts | 305 --- market-maker/src/pricing/reservationPrice.ts | 111 - market-maker/src/registry.ts | 38 - market-maker/src/sizing/geometricTaper.ts | 36 - .../tests/apps/futures/config.test.ts | 135 + .../tests/apps/futures/main.smoke.test.ts | 29 + .../tests/{ => apps/perps}/config.test.ts | 129 +- .../tests/apps/perps/main.smoke.test.ts | 29 + .../tests/{ => core}/errSerializer.test.ts | 2 +- .../tests/{ => core}/gasTracker.test.ts | 2 +- market-maker/tests/{ => core}/helpers.test.ts | 2 +- market-maker/tests/{ => core}/math.test.ts | 2 +- .../tests/{ => core}/oracleTracker.test.ts | 22 +- .../pricing}/reservationPrice.test.ts | 12 +- .../{ => core}/realizedVolatility.test.ts | 2 +- .../tests/{ => core}/riskManager.test.ts | 150 +- .../{ => core/sizing}/geometricTaper.test.ts | 2 +- 93 files changed, 11072 insertions(+), 5075 deletions(-) create mode 100644 .github/workflows/market-maker-tests.yml create mode 100644 market-maker/.vscode/settings.json create mode 100644 market-maker/configs/futures.dev.yml create mode 100644 market-maker/configs/futures.local.yml create mode 100644 market-maker/configs/futures.prd.yml create mode 100644 market-maker/configs/futures.stg.yml delete mode 100644 market-maker/configs/futures.yml create mode 100644 market-maker/configs/perps.dev.yml create mode 100644 market-maker/configs/perps.local.yml create mode 100644 market-maker/configs/perps.prd.yml create mode 100644 market-maker/configs/perps.stg.yml delete mode 100644 market-maker/configs/perps.yml create mode 100755 market-maker/docker-entrypoint.sh create mode 100644 market-maker/infra/README.md create mode 100644 market-maker/infra/ecs-task.tf create mode 100644 market-maker/schemas/futures.json create mode 100644 market-maker/schemas/perps.json create mode 100644 market-maker/scripts/gen-schemas.ts create mode 100755 market-maker/scripts/sync-abi.sh create mode 100644 market-maker/src/abi/CollateralVault.ts create mode 100644 market-maker/src/abi/Futures.ts create mode 100644 market-maker/src/abi/FuturesMmExtensions.ts create mode 100644 market-maker/src/abi/HashPowerPerpsDEX.ts create mode 100644 market-maker/src/abi/Multicall3.ts create mode 100644 market-maker/src/abi/PortfolioMarginEngine.ts delete mode 100644 market-maker/src/adapter.ts delete mode 100644 market-maker/src/adapters/futures/abi.ts create mode 100644 market-maker/src/adapters/futures/events.ts delete mode 100644 market-maker/src/adapters/futures/factory.ts create mode 100644 market-maker/src/adapters/futures/instrument.ts create mode 100644 market-maker/src/adapters/futures/ownOrders.ts create mode 100644 market-maker/src/adapters/futures/venue.ts delete mode 100644 market-maker/src/adapters/options-stub/index.ts delete mode 100644 market-maker/src/adapters/perps/abi.ts delete mode 100644 market-maker/src/adapters/perps/collateral.ts create mode 100644 market-maker/src/adapters/perps/events.ts delete mode 100644 market-maker/src/adapters/perps/factory.ts create mode 100644 market-maker/src/adapters/perps/instrument.ts create mode 100644 market-maker/src/adapters/perps/venue.ts create mode 100644 market-maker/src/apps/futures/config.ts create mode 100644 market-maker/src/apps/futures/main.ts create mode 100644 market-maker/src/apps/perps/config.ts create mode 100644 market-maker/src/apps/perps/main.ts delete mode 100644 market-maker/src/config.ts create mode 100644 market-maker/src/core/adapter.ts rename market-maker/src/{ => core}/bookTracker.ts (53%) rename market-maker/src/{ => core}/client.ts (87%) create mode 100644 market-maker/src/core/collateralTracker.ts create mode 100644 market-maker/src/core/config/base.ts create mode 100644 market-maker/src/core/env.ts rename market-maker/src/{ => core}/errSerializer.ts (100%) rename market-maker/src/{ => core}/errors.ts (100%) rename market-maker/src/{ => core}/gasTracker.ts (92%) rename market-maker/src/{ => core}/healthcheck.ts (64%) rename market-maker/src/{ => core}/helpers.ts (73%) create mode 100644 market-maker/src/core/inventoryManager.ts rename market-maker/src/{ => core}/math.ts (75%) rename market-maker/src/{ => core}/oracleTracker.ts (100%) rename market-maker/src/{ => core}/orderExecutor.ts (56%) rename market-maker/src/{ => core}/pricing/effectiveSpread.ts (55%) create mode 100644 market-maker/src/core/pricing/reservationPrice.ts rename market-maker/src/{ => core}/quoter.ts (65%) rename market-maker/src/{ => core}/rational.ts (77%) rename market-maker/src/{ => core}/riskManager.ts (53%) create mode 100644 market-maker/src/core/runner.ts create mode 100644 market-maker/src/core/sizing/geometricTaper.ts rename market-maker/src/{ => core}/sizing/linear.ts (50%) create mode 100644 market-maker/src/core/vaultDeposit.ts rename market-maker/src/{ => core}/wallet.ts (74%) delete mode 100644 market-maker/src/index.ts delete mode 100644 market-maker/src/inventoryManager.ts delete mode 100644 market-maker/src/main.ts delete mode 100644 market-maker/src/pricing/reservationPrice.ts delete mode 100644 market-maker/src/registry.ts delete mode 100644 market-maker/src/sizing/geometricTaper.ts create mode 100644 market-maker/tests/apps/futures/config.test.ts create mode 100644 market-maker/tests/apps/futures/main.smoke.test.ts rename market-maker/tests/{ => apps/perps}/config.test.ts (53%) create mode 100644 market-maker/tests/apps/perps/main.smoke.test.ts rename market-maker/tests/{ => core}/errSerializer.test.ts (96%) rename market-maker/tests/{ => core}/gasTracker.test.ts (98%) rename market-maker/tests/{ => core}/helpers.test.ts (99%) rename market-maker/tests/{ => core}/math.test.ts (99%) rename market-maker/tests/{ => core}/oracleTracker.test.ts (81%) rename market-maker/tests/{ => core/pricing}/reservationPrice.test.ts (92%) rename market-maker/tests/{ => core}/realizedVolatility.test.ts (98%) rename market-maker/tests/{ => core}/riskManager.test.ts (58%) rename market-maker/tests/{ => core/sizing}/geometricTaper.test.ts (97%) diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml new file mode 100644 index 0000000..11317cb --- /dev/null +++ b/.github/workflows/market-maker-tests.yml @@ -0,0 +1,67 @@ +name: Market maker tests + +on: + pull_request: + paths: + - "market-maker/**" + - ".github/workflows/market-maker-tests.yml" + push: + branches: + - main + - stg + - dev + paths: + - "market-maker/**" + - ".github/workflows/market-maker-tests.yml" + +jobs: + pre: + name: Skip duplicate runs + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip-check.outputs.should_skip }} + steps: + - id: skip-check + uses: fkirc/skip-duplicate-actions@v5 + with: + concurrent_skipping: never + skip_after_successful_duplicate: "true" + paths_ignore: '["**/*.md"]' + + test: + name: Market maker tests + needs: pre + if: needs.pre.outputs.should_skip != 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ./market-maker + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: market-maker/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + cache-dependency-path: market-maker/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - 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. + - name: Run tests + run: node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts' diff --git a/market-maker/.vscode/settings.json b/market-maker/.vscode/settings.json new file mode 100644 index 0000000..7e5d4a7 --- /dev/null +++ b/market-maker/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "yaml.schemas": { + "./schemas/perps.json": "configs/perps.*.yml", + "./schemas/futures.json": "configs/futures.*.yml" + }, + "yaml.format.enable": true, + "yaml.validate": true, + "yaml.hover": true, + "yaml.completion": true, + "files.associations": { + "configs/*.yml": "yaml" + } +} diff --git a/market-maker/Dockerfile b/market-maker/Dockerfile index f268867..af91ad7 100644 --- a/market-maker/Dockerfile +++ b/market-maker/Dockerfile @@ -21,8 +21,14 @@ COPY --from=deps /app/node_modules node_modules/ COPY package.json tsconfig.json ./ COPY src/ src/ COPY configs/ configs/ +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN addgroup -S maker && adduser -S maker -G maker USER maker -CMD ["node", "src/main.ts"] +# MAKER_APP selects the entrypoint: +# MAKER_APP=perps -> src/apps/perps/main.ts +# MAKER_APP=futures -> src/apps/futures/main.ts +# MAKER_CONFIG points to the YAML file inside the container. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/market-maker/README.md b/market-maker/README.md index 8a83ea7..a8f9b71 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -82,7 +82,34 @@ On `SIGINT` / `SIGTERM`, the bot cancels all resting orders before exiting. ## Configuration -All configuration is via environment variables. Create a `.env` file in the repo root (loaded via `--env-file`). +The MM ships per-app, per-env YAML configs under `configs/`: + +| File | Network | Notes | +|---|---|---| +| `perps.local.yml` / `futures.local.yml` | hardhat | Local development; dry-run on by default | +| `perps.dev.yml` / `futures.dev.yml` | base-sepolia | Testnet; small sizes | +| `perps.stg.yml` / `futures.stg.yml` | base | Pre-prod on mainnet; conservative caps | +| `perps.prd.yml` / `futures.prd.yml` | base | Production; full sizes | + +Pick one with `--config ` (CLI arg), `MAKER_CONFIG=` (env), +or `MAKER_ENV=` inside the docker entrypoint. + +Precedence: `--config` CLI arg > `MAKER_CONFIG` env > docker `MAKER_ENV` lookup. + +The YAMLs interpolate `${VAR}` tokens from environment variables. On +startup both apps load `.env` from `market-maker/` and from +`collateral-margin/` (in that priority order). Live `process.env` always +wins over file contents. + +```bash +pnpm local:perps # node … --config configs/perps.local.yml | pino-pretty +pnpm dev:futures # node … --config configs/futures.dev.yml | pino-pretty +pnpm stg:perps # node … --config configs/perps.stg.yml +pnpm prd:futures # node … --config configs/futures.prd.yml + +# Custom path (e.g. one-off experiment): +node src/apps/perps/main.ts --config /tmp/my-perps.yml +``` ### Required @@ -90,8 +117,9 @@ All configuration is via environment variables. Create a `.env` file in the repo |---|---| | `NETWORK` | Chain identifier: `arbitrum`, `arbitrum-sepolia`, or `hardhat` | | `ETH_NODE_ADDRESS` | RPC endpoint (HTTP or WebSocket) | -| `PERPS_ADDRESS` | Deployed HashPowerPerpsDEX proxy contract address | -| `MAKER_PRIVATE_KEY` | Hex-encoded private key for the MM wallet | +| `PERPS_ADDRESS` | Deployed HashPowerPerpsDEX proxy contract address (perps app) | +| `FUTURES_ADDRESS` | Deployed Futures proxy contract address (futures app) | +| `PRIVATE_KEY` | Hex-encoded private key for the MM wallet | ### Quoting @@ -170,14 +198,21 @@ cp abi/abi.ts ../market-maker/src/abi.ts ### Run ```bash -# Production (reads .env from repo root) -pnpm start +# Local hardhat (pretty-printed logs, dry-run on by default) +pnpm local:perps +pnpm local:futures -# Development with pretty-printed logs -pnpm dev +# base-sepolia (dev testnet) +pnpm dev:perps +pnpm dev:futures -# Dry run (no transactions, logs what would happen) -pnpm dev:dry +# base-mainnet (staging — pre-prod sizes) +pnpm stg:perps +pnpm stg:futures + +# base-mainnet (production) +pnpm prd:perps +pnpm prd:futures ``` ### Example `.env` @@ -186,13 +221,8 @@ pnpm dev:dry NETWORK=arbitrum-sepolia ETH_NODE_ADDRESS=https://sepolia-rollup.arbitrum.io/rpc PERPS_ADDRESS=0x... -MAKER_PRIVATE_KEY=0x... - -MAKER_LEVELS_PER_SIDE=3 -MAKER_BASE_QUANTITY=1000000 -MAKER_MIN_SPREAD_BPS=30 -MAKER_POLL_INTERVAL_MS=5000 -MAKER_MAX_POSITION_SIZE=50000000 +FUTURES_ADDRESS=0x... +PRIVATE_KEY=0x... MAKER_DRY_RUN=false MAKER_HEALTH_PORT=3001 ``` diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml new file mode 100644 index 0000000..b926b55 --- /dev/null +++ b/market-maker/configs/futures.dev.yml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=../schemas/futures.json +# Titan Market Maker - Futures - DEV (base-sepolia). +# +# PRIVATE_KEY - hex private key of the dev market-making wallet +# ETH_NODE_ADDRESS - base-sepolia JSON-RPC endpoint +# FUTURES_ADDRESS - Futures address on base-sepolia +# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia + +nodeEnv: development +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base-sepolia + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "100000000" # 100 USDC per side total + numLevelsPerSide: 4 + taperRatio: 0.6 + +risk: + maxPositionSize: "50000000" + maxUtilizationPct: 80 + minCollateralBalance: "10000000" + maxDailyLossUsd: "500000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml new file mode 100644 index 0000000..b5cd7f8 --- /dev/null +++ b/market-maker/configs/futures.local.yml @@ -0,0 +1,66 @@ +# yaml-language-server: $schema=../schemas/futures.json +# Titan Market Maker - Futures - LOCAL (hardhat). +# +# PRIVATE_KEY - hex private key of the market-making wallet +# FUTURES_ADDRESS - Futures address on the local chain + +nodeEnv: development +commitHash: ${COMMIT_HASH:-dev} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-true} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: ${NETWORK:-hardhat} + rpcUrl: ${ETH_NODE_ADDRESS:-http://127.0.0.1:8545} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 20 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "10000000" # 10 USDC per side total — tiny + numLevelsPerSide: 3 + taperRatio: 0.6 + +risk: + maxPositionSize: "10000000" + maxUtilizationPct: 80 + minCollateralBalance: "1000000" + maxDailyLossUsd: "100000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 1 + +collateral: + autoDeposit: false + autoDepositMinAmount: "0" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml new file mode 100644 index 0000000..cfcea7c --- /dev/null +++ b/market-maker/configs/futures.prd.yml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=../schemas/futures.json +# Titan Market Maker - Futures - PRODUCTION (base-mainnet). +# +# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) +# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet + +nodeEnv: production +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-info} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "500000000" # 500 USDC per side total + numLevelsPerSide: 4 + taperRatio: 0.6 + +risk: + maxPositionSize: "1000000000" # 1000 USDC + maxUtilizationPct: 75 + minCollateralBalance: "100000000" + maxDailyLossUsd: "1000000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml new file mode 100644 index 0000000..8343463 --- /dev/null +++ b/market-maker/configs/futures.stg.yml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=../schemas/futures.json +# Titan Market Maker - Futures - STAGING (base-mainnet). +# +# PRIVATE_KEY - hex private key of the staging market-making wallet +# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet + +nodeEnv: staging +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "100000000" # 100 USDC per side total + numLevelsPerSide: 4 + taperRatio: 0.6 + +risk: + maxPositionSize: "50000000" + maxUtilizationPct: 80 + minCollateralBalance: "10000000" + maxDailyLossUsd: "500000000" + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.yml b/market-maker/configs/futures.yml deleted file mode 100644 index 779965f..0000000 --- a/market-maker/configs/futures.yml +++ /dev/null @@ -1,80 +0,0 @@ -# Titan Market Maker - Futures configuration. -# -# Required env vars (loaded via ${VAR}): -# MAKER_PRIVATE_KEY - hex private key of the market-making wallet -# ETH_NODE_ADDRESS - JSON-RPC or websocket endpoint -# FUTURES_ADDRESS - Futures contract address on the configured network -# NETWORK - one of: arbitrum, arbitrum-sepolia, base, base-sepolia, hardhat -# -# Optional env vars (with defaults expressed below): -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator (omit to disable USD gas accounting) -# COMMIT_HASH - injected by CI for /health diagnostics -# NODE_ENV - "development" or "production" -# MAKER_LOG_LEVEL - pino level - -nodeEnv: ${NODE_ENV:-development} -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-info} -dryRun: false - -wallets: - primary: - privateKey: ${MAKER_PRIVATE_KEY} - -network: - name: ${NETWORK} - rpcUrl: ${ETH_NODE_ADDRESS} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: futures - address: ${FUTURES_ADDRESS} - wallet: primary - # Seed BookTracker/InventoryManager from this block on startup (events scan). - # Default: use the contract's "list" view functions instead when available. - # eventsFromBlock: 0 - -pricing: - strategy: reservation-price - # Avellaneda–Stoikov risk aversion γ. - # With oracle.volatility in units of "per poll" (~3 s) and marginCallTimeSeconds - # in seconds, tune riskAversion so that at max inventory the mid shifts by ~1 tick. - riskAversion: 0.001 - # Fallback remaining time (seconds) when InstrumentContext.deliveryDate is unavailable. - marginCallTimeSeconds: 3600 - minSpreadBps: 15 - volatilityMultiplier: 2.5 - # No inventorySkewGamma: reservation-price absorbs inventory through the formula. - maxSkewTicks: 0 - -sizing: - strategy: geometric-taper - # Total per-side budget in USDC base units (6 decimals). - # 500 USDC per side = 500_000_000 - baseQuantity: "500000000" - numLevelsPerSide: 4 - # alpha: 0.6 → each level is 60% of the previous - taperRatio: 0.6 - -risk: - maxPositionSize: "100000000" - maxUtilizationPct: 80 - minCollateralBalance: "100000000" - maxDailyLossUsd: "1000000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalMs: 3000 - requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 - -health: - port: 3001 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml new file mode 100644 index 0000000..98558e3 --- /dev/null +++ b/market-maker/configs/perps.dev.yml @@ -0,0 +1,69 @@ +# yaml-language-server: $schema=../schemas/perps.json +# Titan Market Maker - Perps - DEV (base-sepolia). +# +# Real quoting on base-sepolia. Small sizes, debug-level logs, autoDeposit +# on so the wallet stays funded. +# +# PRIVATE_KEY - hex private key of the dev market-making wallet +# ETH_NODE_ADDRESS - base-sepolia JSON-RPC endpoint +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia +# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia + +nodeEnv: development +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base-sepolia + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + +sizing: + strategy: linear + baseQuantity: "1000000" # 1 USDC per level + numLevelsPerSide: 5 + +risk: + maxPositionSize: "50000000" # 50 USDC + maxUtilizationPct: 80 + minCollateralBalance: "10000000" # 10 USDC + maxDailyLossUsd: "500000000" # 500 USDC + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 5 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml new file mode 100644 index 0000000..56c18ab --- /dev/null +++ b/market-maker/configs/perps.local.yml @@ -0,0 +1,70 @@ +# yaml-language-server: $schema=../schemas/perps.json +# Titan Market Maker - Perps - LOCAL (hardhat). +# +# Local development against a hardhat node. Dry-run on by default, +# debug logs, tiny sizes. Override via env vars (loaded from +# market-maker/.env or collateral-margin/.env on startup, in that +# priority order). +# +# PRIVATE_KEY - hex private key of the market-making wallet +# PERPS_ADDRESS - HashPowerPerpsDEX address on the local chain + +nodeEnv: development +commitHash: ${COMMIT_HASH:-dev} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-true} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: ${NETWORK:-hardhat} + rpcUrl: ${ETH_NODE_ADDRESS:-http://127.0.0.1:8545} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + minSpreadBps: 20 # wider in dev so test fills are obvious + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + +sizing: + strategy: linear + baseQuantity: "100000" # 0.1 USDC per level — tiny + numLevelsPerSide: 3 + +risk: + maxPositionSize: "10000000" # 10 USDC + maxUtilizationPct: 80 + minCollateralBalance: "1000000" # 1 USDC + maxDailyLossUsd: "100000000" # 100 USDC + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 5 + +collateral: + # Off in dev so you can inspect un-deposited wallet balance. + autoDeposit: false + autoDepositMinAmount: "0" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml new file mode 100644 index 0000000..b33fc45 --- /dev/null +++ b/market-maker/configs/perps.prd.yml @@ -0,0 +1,70 @@ +# yaml-language-server: $schema=../schemas/perps.json +# Titan Market Maker - Perps - PRODUCTION (base-mainnet). +# +# Real money. Tighter risk caps and a higher utilization headroom; logs at +# info to keep CloudWatch ingestion costs bounded. Tune sizing per +# liquidity provision target. +# +# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) +# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet (USD-denominated risk gates) + +nodeEnv: production +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-info} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + minSpreadBps: 10 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + +sizing: + strategy: linear + baseQuantity: "10000000" # 10 USDC per level + numLevelsPerSide: 5 + +risk: + maxPositionSize: "1000000000" # 1000 USDC + maxUtilizationPct: 75 # tighter than dev/stg + minCollateralBalance: "100000000" # 100 USDC operational floor + maxDailyLossUsd: "1000000000" # 1000 USDC daily loss circuit-breaker + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 5 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml new file mode 100644 index 0000000..c70992d --- /dev/null +++ b/market-maker/configs/perps.stg.yml @@ -0,0 +1,69 @@ +# yaml-language-server: $schema=../schemas/perps.json +# Titan Market Maker - Perps - STAGING (base-mainnet). +# +# Real money on base-mainnet, but pre-prod sizes / risk caps. Debug-level +# logs to make incident triage easier in shared infra. +# +# PRIVATE_KEY - hex private key of the staging market-making wallet +# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet + +nodeEnv: staging +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base + rpcUrl: ${ETH_NODE_ADDRESS} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + +sizing: + strategy: linear + baseQuantity: "1000000" # 1 USDC per level + numLevelsPerSide: 5 + +risk: + maxPositionSize: "50000000" # 50 USDC + maxUtilizationPct: 80 + minCollateralBalance: "10000000" # 10 USDC + maxDailyLossUsd: "500000000" # 500 USDC + maxGasBudgetPerHourUsd: "50000000" + maxGasBudgetPerDayUsd: "500000000" + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalMs: 3000 + requoteThresholdTicks: 2 + requoteCooldownMs: 1000 + resyncIntervalMs: 60000 + levelSpacingTicks: 5 + +collateral: + autoDeposit: true + autoDepositMinAmount: "1000000" + +health: + port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.yml b/market-maker/configs/perps.yml deleted file mode 100644 index a38ac46..0000000 --- a/market-maker/configs/perps.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Titan Market Maker - Perps configuration. -# -# Required env vars (loaded via ${VAR}): -# MAKER_PRIVATE_KEY - hex private key of the market-making wallet -# ETH_NODE_ADDRESS - JSON-RPC or websocket endpoint -# PERPS_ADDRESS - HashPowerPerpsDEX address on the configured network -# NETWORK - one of: arbitrum, arbitrum-sepolia, base, base-sepolia, hardhat -# -# Optional env vars (with defaults expressed below): -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator (omit to disable USD gas accounting) -# COMMIT_HASH - injected by CI for /health diagnostics -# NODE_ENV - "development" or "production" -# MAKER_LOG_LEVEL - pino level - -nodeEnv: ${NODE_ENV:-development} -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-info} -dryRun: false - -wallets: - primary: - privateKey: ${MAKER_PRIVATE_KEY} - -network: - name: ${NETWORK} - rpcUrl: ${ETH_NODE_ADDRESS} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: perps - address: ${PERPS_ADDRESS} - wallet: primary - -pricing: - strategy: effective-spread - minSpreadBps: 10 - volatilityMultiplier: 2.0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - -sizing: - strategy: linear - baseQuantity: "1000000" - numLevelsPerSide: 5 - -risk: - maxPositionSize: "100000000" - maxUtilizationPct: 80 - minCollateralBalance: "100000000" - maxDailyLossUsd: "1000000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalMs: 3000 - requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 - -health: - port: 3001 diff --git a/market-maker/docker-entrypoint.sh b/market-maker/docker-entrypoint.sh new file mode 100755 index 0000000..818e31a --- /dev/null +++ b/market-maker/docker-entrypoint.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Container entrypoint. +# +# Required: +# MAKER_APP - "perps" or "futures" +# +# Config selection (in precedence order): +# 1. CLI arg: docker run … perps --config /custom/path.yml +# 2. MAKER_CONFIG env var +# 3. MAKER_ENV env var → /app/configs/${MAKER_APP}.${MAKER_ENV}.yml +# (MAKER_ENV defaults to "prd" inside containers) +set -eu + +if [ -z "${MAKER_APP:-}" ]; then + echo "MAKER_APP must be set to 'perps' or 'futures'" >&2 + exit 1 +fi + +case "$MAKER_APP" in + perps) + ENTRY="/app/src/apps/perps/main.ts" + ;; + futures) + ENTRY="/app/src/apps/futures/main.ts" + ;; + *) + echo "Unknown MAKER_APP='$MAKER_APP' (expected 'perps' or 'futures')" >&2 + exit 1 + ;; +esac + +# If no --config CLI arg and no MAKER_CONFIG was injected, fall back to +# selecting by MAKER_ENV. Production-by-default for safety in container +# images that lack any explicit configuration. +if [ -z "${MAKER_CONFIG:-}" ]; then + MAKER_ENV="${MAKER_ENV:-prd}" + case "$MAKER_ENV" in + local|dev|stg|prd) ;; + *) + echo "Unknown MAKER_ENV='$MAKER_ENV' (expected 'local', 'dev', 'stg', or 'prd')" >&2 + exit 1 + ;; + esac + export MAKER_CONFIG="/app/configs/${MAKER_APP}.${MAKER_ENV}.yml" +fi + +exec node "$ENTRY" "$@" diff --git a/market-maker/infra/README.md b/market-maker/infra/README.md new file mode 100644 index 0000000..aa4ba58 --- /dev/null +++ b/market-maker/infra/README.md @@ -0,0 +1,64 @@ +# Market-maker infra reference + +The market-maker now ships as a **single Docker image** with two +entrypoints selected via the `MAKER_APP` environment variable. Each +venue runs as its own ECS service with its own wallet, RPC URL, and +config file. + +``` +┌────────────────────────────────────┐ ┌────────────────────────────────────┐ +│ ECS service: market-maker-perps │ │ ECS service: market-maker-futures │ +│ image: titan-market-maker:sha │ │ image: titan-market-maker:sha │ +│ env: MAKER_APP=perps │ │ env: MAKER_APP=futures │ +│ MAKER_CONFIG=/configs/perps.yml │ MAKER_CONFIG=/configs/futures.yml │ +│ secrets: PRIVATE_KEY (perps) │ │ secrets: PRIVATE_KEY (futures) │ +│ ETH_NODE_ADDRESS │ │ ETH_NODE_ADDRESS │ +│ PERPS_ADDRESS │ │ FUTURES_ADDRESS │ +└────────────────────────────────────┘ └────────────────────────────────────┘ +``` + +The two services share **nothing at runtime** — separate ECS task +defs, separate wallets, separate logs. They only share the image so a +single `docker push` rolls both venues forward (each can still be +pinned to a different image tag). + +## Files + +* `ecs-task.tf` — reusable Terraform module template for one MM service. + Drop this into both the `perps/.bedrock/.terragrunt/` and + `futures-marketplace/.bedrock/.terragrunt/` folders, parameterised + per-venue. + +## Migration notes + +### Perps repo (`perps/`) + +The existing perps MM is already an ECS service. Replace the legacy +task definition (which pointed at the in-repo `market-maker/` +TypeScript) with one that uses: + +* image: `ghcr.io/lumerin-protocol/titan-market-maker:` +* env: `MAKER_APP=perps` +* env: `MAKER_CONFIG=/app/configs/perps.yml` +* secrets: `PRIVATE_KEY` from Secrets Manager (per-venue secret) + +Then delete the in-repo `perps/market-maker/` and its +`.github/workflows/{deploy-market-maker,market-maker-tests}.yml`. + +### Futures-marketplace repo (`futures-marketplace/`) + +The existing futures MM is a **Lambda** (see +`futures-marketplace/.bedrock/.terragrunt/10_market_maker_lambda.tf`). +The new MM is a long-running process — a Lambda doesn't fit. Replace +the entire `10_market_maker_lambda.tf` with an ECS service definition +based on `ecs-task.tf` here. Reuse the existing +`aws_secretsmanager_secret.market_maker` (rename if desired). + +Then delete the in-repo `futures-marketplace/market-maker/` and its +`.github/workflows/{deploy-market-maker,test-market-maker}.yml`. + +## Image build + +CI in `collateral-margin/.github/workflows/` builds and pushes the +shared image. Both repos consume it via image tag (sha-pinned). No +cross-repo build coordination needed. diff --git a/market-maker/infra/ecs-task.tf b/market-maker/infra/ecs-task.tf new file mode 100644 index 0000000..bbe9fe0 --- /dev/null +++ b/market-maker/infra/ecs-task.tf @@ -0,0 +1,148 @@ +# Reference Terraform for one MM ECS service. +# +# Copy this file into the consuming monorepo's terragrunt folder +# (perps/.bedrock/.terragrunt/ or futures-marketplace/.bedrock/.terragrunt/) +# and parameterise per venue: +# +# - var.maker_app "perps" | "futures" +# - var.maker_env "dev" | "stg" | "prd" — picks configs/..yml +# - var.image_tag git-sha pinned image tag +# - var.contract_address PERPS_ADDRESS or FUTURES_ADDRESS +# - var.network "arbitrum" | "base" | etc. +# - var.eth_node_address JSON-RPC or websocket endpoint +# - var.eth_price_feed_address optional Chainlink ETH/USD feed +# - var.private_key_secret_arn AWS Secrets Manager ARN with the wallet PK +# +# The task def expects the image at: +# ghcr.io/lumerin-protocol/titan-market-maker:${var.image_tag} +# +# Inside the container: +# - MAKER_APP selects the entrypoint script in /app/docker-entrypoint.sh +# - MAKER_CONFIG points at /app/configs/${MAKER_APP}.yml (default). +# - All ${VAR} tokens in the YAML are expanded from this env block at boot. + +resource "aws_cloudwatch_log_group" "market_maker" { + name = "/ecs/market-maker-${var.maker_app}-${var.account_shortname}" + retention_in_days = 7 + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Market Maker (${var.maker_app}) Logs" + Capability = null + }) +} + +resource "aws_iam_role" "market_maker_task_exec" { + name = "market-maker-${var.maker_app}-task-exec-${var.account_shortname}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "market_maker_task_exec_basic" { + role = aws_iam_role.market_maker_task_exec.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "market_maker_secrets_access" { + name = "market-maker-${var.maker_app}-secrets" + role = aws_iam_role.market_maker_task_exec.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [var.private_key_secret_arn] + }] + }) +} + +resource "aws_ecs_task_definition" "market_maker" { + family = "market-maker-${var.maker_app}-${var.account_shortname}" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "512" + memory = "1024" + execution_role_arn = aws_iam_role.market_maker_task_exec.arn + # task_role_arn intentionally omitted: the container itself doesn't + # need AWS API access. Secrets are injected by ECS via execution role. + + container_definitions = jsonencode([{ + name = "market-maker" + image = "ghcr.io/lumerin-protocol/titan-market-maker:${var.image_tag}" + essential = true + + environment = [ + { name = "MAKER_APP", value = var.maker_app }, + { name = "MAKER_ENV", value = var.maker_env }, + { name = "MAKER_CONFIG", value = "/app/configs/${var.maker_app}.${var.maker_env}.yml" }, + { name = "NODE_ENV", value = "production" }, + { name = "MAKER_LOG_LEVEL", value = "info" }, + { name = "NETWORK", value = var.network }, + { name = "ETH_NODE_ADDRESS", value = var.eth_node_address }, + { name = "ETH_PRICE_FEED_ADDRESS", value = var.eth_price_feed_address }, + { name = var.maker_app == "perps" ? "PERPS_ADDRESS" : "FUTURES_ADDRESS", value = var.contract_address }, + ] + + secrets = [ + { name = "PRIVATE_KEY", valueFrom = var.private_key_secret_arn }, + ] + + portMappings = [{ + containerPort = 3001 + protocol = "tcp" + }] + + healthCheck = { + command = ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3001/health || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.market_maker.name + awslogs-region = var.region + awslogs-stream-prefix = "market-maker" + } + } + }]) + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Market Maker (${var.maker_app})" + Capability = null + }) +} + +resource "aws_ecs_service" "market_maker" { + name = "market-maker-${var.maker_app}-${var.account_shortname}" + cluster = var.ecs_cluster_arn + task_definition = aws_ecs_task_definition.market_maker.arn + desired_count = 1 + launch_type = "FARGATE" + enable_execute_command = true # for `aws ecs execute-command` debugging + + # Run-once-at-a-time semantics: only one MM per venue. + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + network_configuration { + subnets = var.subnet_ids + security_groups = [var.security_group_id] + assign_public_ip = false + } + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Market Maker (${var.maker_app})" + Capability = null + }) +} diff --git a/market-maker/package.json b/market-maker/package.json index 174d559..a49b33a 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -9,10 +9,18 @@ "scripts": { "test": "node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", "typecheck": "tsc --noEmit", - "start": "MAKER_CONFIG=configs/perps.yml node --env-file=../.env src/main.ts", - "dev": "MAKER_CONFIG=configs/perps.yml node --env-file=../.env src/main.ts | pino-pretty", - "dev:dry": "MAKER_CONFIG=configs/perps.yml MAKER_DRY_RUN=true node --env-file=../.env src/main.ts | pino-pretty", - "pretest": "cd ../contracts && pnpm hardhat compile && cp ./abi/abi.ts ../market-maker/src/adapters/perps/abi.ts" + "local:perps": "node src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", + "local:futures": "node src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", + "dev:perps": "node src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", + "dev:futures": "node src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", + "stg:perps": "node src/apps/perps/main.ts --config configs/perps.stg.yml", + "stg:futures": "node src/apps/futures/main.ts --config configs/futures.stg.yml", + "prd:perps": "node src/apps/perps/main.ts --config configs/perps.prd.yml", + "prd:futures": "node src/apps/futures/main.ts --config configs/futures.prd.yml", + "sync-abi": "./scripts/sync-abi.sh", + "gen:schemas": "node scripts/gen-schemas.ts", + "pretest": "./scripts/sync-abi.sh", + "pretypecheck": "node scripts/gen-schemas.ts" }, "dependencies": { "ajv": "^8.17.1", diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json new file mode 100644 index 0000000..818dbc6 --- /dev/null +++ b/market-maker/schemas/futures.json @@ -0,0 +1,508 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Titan Market Maker - Futures config", + "additionalProperties": false, + "type": "object", + "required": [ + "nodeEnv", + "commitHash", + "logLevel", + "dryRun", + "wallets", + "network", + "venue", + "pricing", + "sizing", + "risk", + "gas", + "collateral", + "timing", + "health" + ], + "properties": { + "nodeEnv": { + "default": "development", + "type": "string" + }, + "commitHash": { + "default": "unknown", + "type": "string" + }, + "logLevel": { + "default": "info", + "type": "string" + }, + "dryRun": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "wallets": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "additionalProperties": false, + "type": "object", + "required": [ + "privateKey" + ], + "properties": { + "privateKey": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + } + } + }, + "network": { + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "rpcUrl" + ], + "properties": { + "name": { + "type": "string" + }, + "rpcUrl": { + "type": "string" + }, + "ethPriceFeed": { + "anyOf": [ + { + "const": "", + "type": "string" + }, + { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "venue": { + "additionalProperties": false, + "type": "object", + "required": [ + "kind", + "address", + "wallet" + ], + "properties": { + "kind": { + "const": "futures", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "wallet": { + "type": "string" + } + } + }, + "pricing": { + "additionalProperties": false, + "type": "object", + "required": [ + "strategy", + "riskAversion", + "marginCallTimeSeconds", + "minSpreadBps", + "volatilityMultiplier", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "const": "reservation-price", + "type": "string" + }, + "riskAversion": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "marginCallTimeSeconds": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "maxSkewTicks": { + "const": 0, + "default": 0, + "type": "number" + } + } + }, + "sizing": { + "additionalProperties": false, + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide", + "taperRatio" + ], + "properties": { + "strategy": { + "const": "geometric-taper", + "type": "string" + }, + "baseQuantity": { + "type": "string" + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "taperRatio": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + }, + "risk": { + "additionalProperties": false, + "type": "object", + "required": [ + "maxPositionSize", + "maxUtilizationPct", + "minCollateralBalance", + "maxDailyLossUsd", + "maxGasBudgetPerHourUsd", + "maxGasBudgetPerDayUsd", + "gasSpikeThresholdPct", + "gasPenaltyBps", + "urgentRequoteThresholdTicks" + ], + "properties": { + "maxPositionSize": { + "type": "string" + }, + "maxUtilizationPct": { + "anyOf": [ + { + "minimum": 0, + "maximum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 80 + }, + "minCollateralBalance": { + "type": "string" + }, + "maxDailyLossUsd": { + "type": "string" + }, + "maxGasBudgetPerHourUsd": { + "default": "50000000", + "type": "string" + }, + "maxGasBudgetPerDayUsd": { + "default": "500000000", + "type": "string" + }, + "gasSpikeThresholdPct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 200 + }, + "gasPenaltyBps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 5 + }, + "urgentRequoteThresholdTicks": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 10 + } + } + }, + "gas": { + "additionalProperties": false, + "type": "object", + "required": [ + "gasCapMultiplier" + ], + "properties": { + "gasCapMultiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 2 + } + } + }, + "collateral": { + "additionalProperties": false, + "type": "object", + "required": [ + "autoDeposit", + "autoDepositMinAmount" + ], + "properties": { + "autoDeposit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "autoDepositMinAmount": { + "default": "0", + "type": "string" + } + } + }, + "timing": { + "additionalProperties": false, + "type": "object", + "required": [ + "pollIntervalMs", + "requoteThresholdTicks", + "requoteCooldownMs", + "resyncIntervalMs", + "levelSpacingTicks" + ], + "properties": { + "pollIntervalMs": { + "anyOf": [ + { + "minimum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 3000 + }, + "requoteThresholdTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 2 + }, + "requoteCooldownMs": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 1000 + }, + "resyncIntervalMs": { + "anyOf": [ + { + "minimum": 1000, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 60000 + }, + "levelSpacingTicks": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 1 + } + } + }, + "health": { + "additionalProperties": false, + "type": "object", + "required": [ + "port" + ], + "properties": { + "port": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 3001 + } + } + } + } +} diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json new file mode 100644 index 0000000..01e6f6d --- /dev/null +++ b/market-maker/schemas/perps.json @@ -0,0 +1,487 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Titan Market Maker - Perps config", + "additionalProperties": false, + "type": "object", + "required": [ + "nodeEnv", + "commitHash", + "logLevel", + "dryRun", + "wallets", + "network", + "venue", + "pricing", + "sizing", + "risk", + "gas", + "collateral", + "timing", + "health" + ], + "properties": { + "nodeEnv": { + "default": "development", + "type": "string" + }, + "commitHash": { + "default": "unknown", + "type": "string" + }, + "logLevel": { + "default": "info", + "type": "string" + }, + "dryRun": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "wallets": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "additionalProperties": false, + "type": "object", + "required": [ + "privateKey" + ], + "properties": { + "privateKey": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + } + } + }, + "network": { + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "rpcUrl" + ], + "properties": { + "name": { + "type": "string" + }, + "rpcUrl": { + "type": "string" + }, + "ethPriceFeed": { + "anyOf": [ + { + "const": "", + "type": "string" + }, + { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "venue": { + "additionalProperties": false, + "type": "object", + "required": [ + "kind", + "address", + "wallet" + ], + "properties": { + "kind": { + "const": "perps", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "wallet": { + "type": "string" + } + } + }, + "pricing": { + "additionalProperties": false, + "type": "object", + "required": [ + "strategy", + "minSpreadBps", + "volatilityMultiplier", + "inventorySkewGamma", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "const": "effective-spread", + "type": "string" + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "inventorySkewGamma": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + "maxSkewTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + }, + "sizing": { + "additionalProperties": false, + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide" + ], + "properties": { + "strategy": { + "const": "linear", + "type": "string" + }, + "baseQuantity": { + "type": "string" + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + }, + "risk": { + "additionalProperties": false, + "type": "object", + "required": [ + "maxPositionSize", + "maxUtilizationPct", + "minCollateralBalance", + "maxDailyLossUsd", + "maxGasBudgetPerHourUsd", + "maxGasBudgetPerDayUsd", + "gasSpikeThresholdPct", + "gasPenaltyBps", + "urgentRequoteThresholdTicks" + ], + "properties": { + "maxPositionSize": { + "type": "string" + }, + "maxUtilizationPct": { + "anyOf": [ + { + "minimum": 0, + "maximum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 80 + }, + "minCollateralBalance": { + "type": "string" + }, + "maxDailyLossUsd": { + "type": "string" + }, + "maxGasBudgetPerHourUsd": { + "default": "50000000", + "type": "string" + }, + "maxGasBudgetPerDayUsd": { + "default": "500000000", + "type": "string" + }, + "gasSpikeThresholdPct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 200 + }, + "gasPenaltyBps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 5 + }, + "urgentRequoteThresholdTicks": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 10 + } + } + }, + "gas": { + "additionalProperties": false, + "type": "object", + "required": [ + "gasCapMultiplier" + ], + "properties": { + "gasCapMultiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 2 + } + } + }, + "collateral": { + "additionalProperties": false, + "type": "object", + "required": [ + "autoDeposit", + "autoDepositMinAmount" + ], + "properties": { + "autoDeposit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "autoDepositMinAmount": { + "default": "0", + "type": "string" + } + } + }, + "timing": { + "additionalProperties": false, + "type": "object", + "required": [ + "pollIntervalMs", + "requoteThresholdTicks", + "requoteCooldownMs", + "resyncIntervalMs", + "levelSpacingTicks" + ], + "properties": { + "pollIntervalMs": { + "anyOf": [ + { + "minimum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 3000 + }, + "requoteThresholdTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 2 + }, + "requoteCooldownMs": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 1000 + }, + "resyncIntervalMs": { + "anyOf": [ + { + "minimum": 1000, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 60000 + }, + "levelSpacingTicks": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 1 + } + } + }, + "health": { + "additionalProperties": false, + "type": "object", + "required": [ + "port" + ], + "properties": { + "port": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 3001 + } + } + } + } +} diff --git a/market-maker/scripts/gen-schemas.ts b/market-maker/scripts/gen-schemas.ts new file mode 100644 index 0000000..7d822b6 --- /dev/null +++ b/market-maker/scripts/gen-schemas.ts @@ -0,0 +1,99 @@ +// Emits JSON Schema for each per-app config under schemas/. +// +// The YAML language server (Red Hat YAML extension shipped with VS Code, +// Cursor, and most JetBrains IDEs) reads the `# yaml-language-server: +// $schema=…` comment at the top of each YAML and offers autocompletion, +// hover docs, and validation against the schema. +// +// Run: pnpm gen:schemas +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { perpsRootSchema } from "../src/apps/perps/config.ts"; +import { futuresRootSchema } from "../src/apps/futures/config.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, "..", "schemas"); +mkdirSync(outDir, { recursive: true }); + +/** + * Pattern for ${VAR} or ${VAR:-default} env-interpolation tokens. + * Editor-time the YAML still has the literal placeholder; the runtime + * validator only sees the expanded value, so for editor consumption we + * relax leaf types that wouldn't otherwise accept a `${...}` string. + */ +const ENV_VAR_PATTERN = "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$"; +const ENV_VAR_ALT = { + type: "string", + pattern: ENV_VAR_PATTERN, + description: "Environment variable interpolation (resolved at startup)", +} as const; + +type AnyObj = Record; + +/** + * Walks a JSON-Schema tree and rewrites leaf types so editors accept + * `${VAR}` placeholders alongside the original constraint: + * - string with pattern -> anyOf [original, env-var string] + * - boolean/number -> anyOf [original, env-var string] + * - enum/const -> left alone (these are intentional literals) + */ +function relaxForEnvInterpolation(node: unknown): unknown { + if (node === null || typeof node !== "object") return node; + if (Array.isArray(node)) return node.map(relaxForEnvInterpolation); + + const src = node as AnyObj; + const out: AnyObj = { ...src }; + + for (const k of ["properties", "patternProperties", "definitions", "$defs"] as const) { + const v = out[k]; + if (v && typeof v === "object" && !Array.isArray(v)) { + const next: AnyObj = {}; + for (const [pk, pv] of Object.entries(v as AnyObj)) next[pk] = relaxForEnvInterpolation(pv); + out[k] = next; + } + } + if (out.items !== undefined) out.items = relaxForEnvInterpolation(out.items); + if (out.additionalProperties && typeof out.additionalProperties === "object") { + out.additionalProperties = relaxForEnvInterpolation(out.additionalProperties); + } + for (const k of ["anyOf", "oneOf", "allOf"] as const) { + if (Array.isArray(out[k])) out[k] = (out[k] as unknown[]).map(relaxForEnvInterpolation); + } + + if (out.const !== undefined || out.enum !== undefined) return out; + + const t = out.type; + const needsRelax = + (t === "string" && typeof out.pattern === "string") || + t === "boolean" || + t === "number" || + t === "integer"; + if (!needsRelax) return out; + + const original: AnyObj = { ...out }; + for (const k of ["title", "description", "default"]) delete original[k]; + return { + anyOf: [original, ENV_VAR_ALT], + ...(out.description !== undefined ? { description: out.description } : {}), + ...(out.default !== undefined ? { default: out.default } : {}), + }; +} + +const targets = [ + { name: "perps", schema: perpsRootSchema, title: "Titan Market Maker - Perps config" }, + { name: "futures", schema: futuresRootSchema, title: "Titan Market Maker - Futures config" }, +] as const; + +for (const t of targets) { + const relaxed = relaxForEnvInterpolation(t.schema) as AnyObj; + const json = { + $schema: "http://json-schema.org/draft-07/schema#", + title: t.title, + ...relaxed, + }; + const path = resolve(outDir, `${t.name}.json`); + writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`, "utf8"); + // biome-ignore lint/suspicious/noConsole: this is a CLI script + console.log(`wrote ${path}`); +} diff --git a/market-maker/scripts/sync-abi.sh b/market-maker/scripts/sync-abi.sh new file mode 100755 index 0000000..47f8ae8 --- /dev/null +++ b/market-maker/scripts/sync-abi.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Copy canonical ABIs from the three contract repos into market-maker. +# +# The three sources of truth: +# collateral-margin/contracts → CollateralVault, PortfolioMarginEngine +# ../perps/contracts → HashPowerPerpsDEX +# ../futures-marketplace/contracts → Futures +# +# The MM never imports from those repos directly — running this script (via +# `pnpm pretest`) keeps a vendored copy under src/abi. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CM_CONTRACTS="$(cd "$ROOT/../contracts" && pwd)" +PERPS_CONTRACTS="$(cd "$ROOT/../../perps/contracts" 2>/dev/null && pwd || true)" +FUTURES_CONTRACTS="$(cd "$ROOT/../../futures-marketplace/contracts" 2>/dev/null && pwd || true)" + +cd "$CM_CONTRACTS" && pnpm hardhat compile >/dev/null +[[ -n "${PERPS_CONTRACTS:-}" ]] && (cd "$PERPS_CONTRACTS" && pnpm hardhat compile >/dev/null) +[[ -n "${FUTURES_CONTRACTS:-}" ]] && (cd "$FUTURES_CONTRACTS" && pnpm hardhat compile >/dev/null) + +mkdir -p "$ROOT/src/abi" + +cp "$CM_CONTRACTS/abi/CollateralVault.ts" "$ROOT/src/abi/CollateralVault.ts" +cp "$CM_CONTRACTS/abi/PortfolioMarginEngine.ts" "$ROOT/src/abi/PortfolioMarginEngine.ts" + +if [[ -n "${PERPS_CONTRACTS:-}" ]]; then + cp "$PERPS_CONTRACTS/abi/HashPowerPerpsDEX.ts" "$ROOT/src/abi/HashPowerPerpsDEX.ts" + cp "$PERPS_CONTRACTS/abi/Multicall3.ts" "$ROOT/src/abi/Multicall3.ts" +fi + +if [[ -n "${FUTURES_CONTRACTS:-}" ]]; then + cp "$FUTURES_CONTRACTS/abi/Futures.ts" "$ROOT/src/abi/Futures.ts" +fi + +echo "ABIs synced to $ROOT/src/abi/" diff --git a/market-maker/src/abi/CollateralVault.ts b/market-maker/src/abi/CollateralVault.ts new file mode 100644 index 0000000..10ff645 --- /dev/null +++ b/market-maker/src/abi/CollateralVault.ts @@ -0,0 +1,940 @@ +export const CollateralVaultAbi = [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "FunctionDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "MarginBreach", + "type": "error" + }, + { + "inputs": [], + "name": "NotAuthorized", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "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": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAmount", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "authorized", + "type": "bool" + } + ], + "name": "AuthorizedCallerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "Deposited", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "source", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "InsuranceFundDeposited", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "InsuranceFundWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "marginEngine", + "type": "address" + } + ], + "name": "MarginEngineSet", + "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": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + } + ], + "name": "Withdrawn", + "type": "event" + }, + { + "inputs": [], + "name": "INSURANCE_FUND_ADDR", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "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": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorizedCallers", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "collateralToken", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "depositFor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "depositInsuranceFund", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_collateralToken", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "insuranceFundBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "internalTransfer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "internalTransferWithMarginCheck", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "marginEngine", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "internalType": "bool", + "name": "authorized", + "type": "bool" + } + ], + "name": "setAuthorizedCaller", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_marginEngine", + "type": "address" + } + ], + "name": "setMarginEngine", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "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": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawInsuranceFund", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/market-maker/src/abi/Futures.ts b/market-maker/src/abi/Futures.ts new file mode 100644 index 0000000..dc36082 --- /dev/null +++ b/market-maker/src/abi/Futures.ts @@ -0,0 +1,1545 @@ +export const FuturesAbi = [ + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_collateralVault", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "CollateralTokenMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "DeliveryDateExpired", + "type": "error" + }, + { + "inputs": [], + "name": "DeliveryDateNotAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "DeliveryDateShouldBeInTheFuture", + "type": "error" + }, + { + "inputs": [], + "name": "DeliveryNotFinishedYet", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reserve", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "required", + "type": "uint256" + } + ], + "name": "InsufficientContractReserve", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMarginBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidQty", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NothingToWithdraw", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyPositionBuyer", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyValidator", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyValidatorOrPositionParticipant", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "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": "PositionAlreadyPaid", + "type": "error" + }, + { + "inputs": [], + "name": "PositionDeliveryExpired", + "type": "error" + }, + { + "inputs": [], + "name": "PositionDeliveryNotStartedYet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionDestURLNotSet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "TransferDisabled", + "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": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BadDebt", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "reclaimedMargin", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "realizedPnl", + "type": "int256" + } + ], + "name": "Liquidation", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + } + ], + "name": "OrderClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "string", + "name": "destURL", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "pricePerDay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "deliveryAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isBuy", + "type": "bool" + } + ], + "name": "OrderCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "orderFee", + "type": "uint256" + } + ], + "name": "OrderFeeUpdated", + "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": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + } + ], + "name": "PositionClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "seller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "buyer", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sellPricePerDay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "buyPricePerDay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "deliveryAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "destURL", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "takerOrderId", + "type": "bytes32" + } + ], + "name": "PositionCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "closedBy", + "type": "address" + } + ], + "name": "PositionDeliveryClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + } + ], + "name": "PositionExited", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + } + ], + "name": "PositionPaid", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + } + ], + "name": "PositionPaymentReceived", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "validatorURL", + "type": "string" + } + ], + "name": "ValidatorURLUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "BREACH_PENALTY_DECIMALS", + "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", + "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": [], + "name": "breachPenaltyRatePerDay", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_positionId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "_blameSeller", + "type": "bool" + } + ], + "name": "closeDelivery", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collateralVault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "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": "_deliveryDate", + "type": "uint256" + }, + { + "internalType": "string", + "name": "_destURL", + "type": "string" + }, + { + "internalType": "int8", + "name": "_qty", + "type": "int8" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "deliveryDurationDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deliveryIntervalDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_positionIds", + "type": "bytes32[]" + } + ], + "name": "depositDeliveryPayment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "positionId", + "type": "bytes32" + } + ], + "name": "depositDeliveryPaymentV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "firstFutureDeliveryDate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureDeliveryDatesCount", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getCollateralDeficit", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getDeliveryDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getFuturesOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getFuturesUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMarketPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getMinMargin", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_entryPricePerDay", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_qty", + "type": "int256" + } + ], + "name": "getMinMarginForPosition", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getNetPositionDelta", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "getOrderById", + "outputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "isBuy", + "type": "bool" + }, + { + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "internalType": "string", + "name": "destURL", + "type": "string" + }, + { + "internalType": "uint256", + "name": "pricePerDay", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deliveryAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "createdAt", + "type": "uint256" + } + ], + "internalType": "struct Futures.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getOrderFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_positionId", + "type": "bytes32" + } + ], + "name": "getPositionById", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "seller", + "type": "address" + }, + { + "internalType": "address", + "name": "buyer", + "type": "address" + }, + { + "internalType": "string", + "name": "destURL", + "type": "string" + }, + { + "internalType": "uint256", + "name": "sellPricePerDay", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyPricePerDay", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deliveryAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "createdAt", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "paid", + "type": "bool" + } + ], + "internalType": "struct Futures.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_deliveryDate", + "type": "uint256" + } + ], + "name": "getPositionsByParticipantDeliveryDate", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hashpriceScalingDivisor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hashrateOracle", + "outputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_hashrateOracle", + "type": "address" + }, + { + "internalType": "address", + "name": "_validatorAddress", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_speedHps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minimumPriceIncrement", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "_deliveryDurationDays", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_deliveryIntervalDays", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_futureDeliveryDatesCount", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_firstFutureDeliveryDate", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "marginCall", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "marginEngine", + "outputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes[]", + "name": "data", + "type": "bytes[]" + } + ], + "name": "multicall", + "outputs": [ + { + "internalType": "bytes[]", + "name": "results", + "type": "bytes[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "orderFee", + "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": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "removeOutdatedOrdersForParticipant", + "outputs": [ + { + "internalType": "uint256", + "name": "count", + "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": "uint256", + "name": "_breachPenaltyRatePerDay", + "type": "uint256" + } + ], + "name": "setBreachPenaltyRatePerDay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_address", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_feeDiscountPercent", + "type": "uint8" + } + ], + "name": "setFeeDiscountPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_futureDeliveryDatesCount", + "type": "uint8" + } + ], + "name": "setFutureDeliveryDatesCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + } + ], + "name": "setLiquidationMarginPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_marginEngine", + "type": "address" + } + ], + "name": "setMarginEngine", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "addr", + "type": "address" + } + ], + "name": "setOracle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_orderFee", + "type": "uint256" + } + ], + "name": "setOrderFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_validatorAddress", + "type": "address" + } + ], + "name": "setValidatorAddress", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_validatorURL", + "type": "string" + } + ], + "name": "setValidatorURL", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "speedHps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "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": "validatorAddress", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "validatorURL", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawCollectedFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_deliveryDate", + "type": "uint256" + } + ], + "name": "withdrawDeliveryPayment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/market-maker/src/abi/FuturesMmExtensions.ts b/market-maker/src/abi/FuturesMmExtensions.ts new file mode 100644 index 0000000..7acc907 --- /dev/null +++ b/market-maker/src/abi/FuturesMmExtensions.ts @@ -0,0 +1,70 @@ +/** + * Hand-authored ABI for the views added to `Futures.sol` for the off-chain + * market maker. These match the entries that `pnpm sync-abi` will eventually + * fold into `Futures.ts`; until then they live here so the adapter compiles + * against the new contract. + * + * Keep in sync with `Futures.sol#getOrderIds / getPositionIds / getBidPrices / + * getAskPrices / getQuantityAtPrice / closeOrder / MAX_ORDER_QTY`. + */ +export const FuturesMmExtensionsAbi = [ + { + inputs: [], + name: "MAX_ORDER_QTY", + outputs: [{ internalType: "int8", name: "", type: "int8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_participant", type: "address" }], + name: "getOrderIds", + outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_participant", type: "address" }], + name: "getPositionIds", + outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, + { internalType: "uint256", name: "_maxLevels", type: "uint256" }, + ], + name: "getBidPrices", + outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, + { internalType: "uint256", name: "_maxLevels", type: "uint256" }, + ], + name: "getAskPrices", + outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_deliveryDate", 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: "bytes32", name: "_orderId", type: "bytes32" }], + name: "closeOrder", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/market-maker/src/abi/HashPowerPerpsDEX.ts b/market-maker/src/abi/HashPowerPerpsDEX.ts new file mode 100644 index 0000000..c14b74d --- /dev/null +++ b/market-maker/src/abi/HashPowerPerpsDEX.ts @@ -0,0 +1,1474 @@ +export const HashPowerPerpsDEXAbi = [ + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minimumPriceIncrement", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientCollateral", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMargin", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientReservePool", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidFundingParameters", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidMarginPercent", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSize", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NotLiquidatable", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderMarginTooLow", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "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": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "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": "uint256", + "name": "maxBps", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "period", + "type": "uint256" + } + ], + "name": "FundingParametersUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "amount", + "type": "int256" + } + ], + "name": "FundingSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int256", + "name": "fundingRate", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "cumulativeFundingPerUnit", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "FundingUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newLiquidationFee", + "type": "uint256" + } + ], + "name": "LiquidationFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newMaintenanceMarginPercent", + "type": "uint8" + } + ], + "name": "MaintenanceMarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newMarginPercent", + "type": "uint8" + } + ], + "name": "MarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newTakerFeeBps", + "type": "int16" + }, + { + "indexed": false, + "internalType": "int16", + "name": "newMakerFeeBps", + "type": "int16" + } + ], + "name": "MatchFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newMinimumMarginPerOrder", + "type": "uint256" + } + ], + "name": "MinimumMarginPerOrderUpdated", + "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" + } + ], + "name": "OrderCreated", + "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": "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": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "positionSize", + "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": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "FUNDING_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORDERS_PER_PARTICIPANT", + "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": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collateralToken", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "cumulativeFundingPerUnit", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "fundingPeriod", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "fundingRateMaxBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getBestAskPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getBestBidPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getInitialMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getMaintenanceMargin", + "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": "struct HashPowerPerpsDEX.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxLevels", + "type": "uint256" + } + ], + "name": "getOrderBookPrices", + "outputs": [ + { + "internalType": "uint256[]", + "name": "bidPrices", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "askPrices", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getPendingFunding", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isBid", + "type": "bool" + } + ], + "name": "getQuantityAtPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "totalQuantity", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getRequiredMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getUserOrders", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getUserPosition", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netQuantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "aggregatedEntryPrice", + "type": "uint256" + } + ], + "internalType": "struct HashPowerPerpsDEX.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getUsersWithPositions", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_priceOracle", + "type": "address" + }, + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + }, + { + "internalType": "contract IPortfolioMarginEngine", + "name": "_pm", + "type": "address" + } + ], + "name": "initializeV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastFundingUpdateTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_users", + "type": "address[]" + } + ], + "name": "liquidateBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maintenanceMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "makerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "marginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumMarginPerOrder", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes[]", + "name": "data", + "type": "bytes[]" + } + ], + "name": "multicall", + "outputs": [ + { + "internalType": "bytes[]", + "name": "results", + "type": "bytes[]" + } + ], + "stateMutability": "nonpayable", + "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": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "resetState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_fundingRateMaxBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_fundingPeriod", + "type": "uint256" + } + ], + "name": "setFundingParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_liquidationFee", + "type": "uint256" + } + ], + "name": "setLiquidationFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_takerFeeBps", + "type": "int16" + }, + { + "internalType": "int16", + "name": "_makerFeeBps", + "type": "int16" + } + ], + "name": "setMatchFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minimumMarginPerOrder", + "type": "uint256" + } + ], + "name": "setMinimumMarginPerOrder", + "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": "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": [], + "name": "updateFunding", + "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" + } +] as const; diff --git a/market-maker/src/abi/Multicall3.ts b/market-maker/src/abi/Multicall3.ts new file mode 100644 index 0000000..a82897c --- /dev/null +++ b/market-maker/src/abi/Multicall3.ts @@ -0,0 +1,440 @@ +export const Multicall3Abi = [ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "aggregate", + "outputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + }, + { + "internalType": "bytes[]", + "name": "returnData", + "type": "bytes[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bool", + "name": "allowFailure", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call3[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "aggregate3", + "outputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Result[]", + "name": "returnData", + "type": "tuple[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bool", + "name": "allowFailure", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call3Value[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "aggregate3Value", + "outputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Result[]", + "name": "returnData", + "type": "tuple[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "blockAndAggregate", + "outputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "blockHash", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Result[]", + "name": "returnData", + "type": "tuple[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "getBasefee", + "outputs": [ + { + "internalType": "uint256", + "name": "basefee", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + } + ], + "name": "getBlockHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "blockHash", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getBlockNumber", + "outputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getChainId", + "outputs": [ + { + "internalType": "uint256", + "name": "chainid", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getCurrentBlockCoinbase", + "outputs": [ + { + "internalType": "address", + "name": "coinbase", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getCurrentBlockDifficulty", + "outputs": [ + { + "internalType": "uint256", + "name": "difficulty", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getCurrentBlockGasLimit", + "outputs": [ + { + "internalType": "uint256", + "name": "gaslimit", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getCurrentBlockTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "addr", + "type": "address" + } + ], + "name": "getEthBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getLastBlockHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "blockHash", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "tryAggregate", + "outputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Result[]", + "name": "returnData", + "type": "tuple[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "tryBlockAndAggregate", + "outputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "blockHash", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "internalType": "struct Multicall3.Result[]", + "name": "returnData", + "type": "tuple[]" + } + ], + "stateMutability": "payable", + "type": "function" + } +] as const; diff --git a/market-maker/src/abi/PortfolioMarginEngine.ts b/market-maker/src/abi/PortfolioMarginEngine.ts new file mode 100644 index 0000000..2ae082c --- /dev/null +++ b/market-maker/src/abi/PortfolioMarginEngine.ts @@ -0,0 +1,588 @@ +export const PortfolioMarginEngineAbi = [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "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": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "futures", + "type": "address" + } + ], + "name": "FuturesUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "optionsEngine", + "type": "address" + } + ], + "name": "OptionsEngineUpdated", + "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": "perpsDex", + "type": "address" + } + ], + "name": "PerpsDexUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "imSpot", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "mmSpot", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "imVol", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "mmVol", + "type": "uint256" + } + ], + "name": "ShocksUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "vault", + "type": "address" + } + ], + "name": "VaultUpdated", + "type": "event" + }, + { + "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": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "additionalIM", + "type": "uint256" + } + ], + "name": "canPlaceOrder", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioIM", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioMM", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futures", + "outputs": [ + { + "internalType": "contract IFutures", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "imSpotShock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "imVolShock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_vault", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isHealthy", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "mmSpotShock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "mmVolShock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "optionsEngine", + "outputs": [ + { + "internalType": "contract IOptionsEnginePortfolioView", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "perpsDex", + "outputs": [ + { + "internalType": "contract IHashPowerPerpsDEX", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_futuresEngine", + "type": "address" + } + ], + "name": "setFutures", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_optionsEngine", + "type": "address" + } + ], + "name": "setOptions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_perpsEngine", + "type": "address" + } + ], + "name": "setPerps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_imSpotShock", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_mmSpotShock", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_imVolShock", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_mmVolShock", + "type": "uint256" + } + ], + "name": "setShocks", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_vault", + "type": "address" + } + ], + "name": "setVault", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "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" + } +] as const; diff --git a/market-maker/src/adapter.ts b/market-maker/src/adapter.ts deleted file mode 100644 index b31deee..0000000 --- a/market-maker/src/adapter.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Chain, PublicClient, Transport } from "viem"; -import type { WalletContext } from "./wallet.ts"; - -export type VenueKind = "perps" | "futures" | "options"; - -/** An order resting on the venue owned by the MM. */ -export interface OwnOrder { - orderId: `0x${string}`; - price: bigint; - /** Signed: positive = buy/long, negative = sell/short. */ - quantity: bigint; - /** Optional instrument identifier (for multi-instrument venues like options). */ - instrumentId?: string; -} - -/** A desired quote level produced by a pricing strategy. */ -export interface QuoteLevel { - price: bigint; - /** Signed: positive = buy/long, negative = sell/short. */ - quantity: bigint; -} - -export interface DesiredQuotes { - bids: QuoteLevel[]; - asks: QuoteLevel[]; -} - -/** Position snapshot for a single instrument. */ -export interface Position { - netQuantity: bigint; - entryPrice: bigint; -} - -/** A single resting price level (one side) of an order book. */ -export interface DepthLevel { - price: bigint; - /** Always positive (aggregate quantity at this price). */ - quantity: bigint; -} - -/** Snapshot of one instrument's order book. */ -export interface OrderBookSnapshot { - bids: DepthLevel[]; - asks: DepthLevel[]; -} - -/** Collateral snapshot shared by the venue's entire account (may span multiple instruments). */ -export interface CollateralSnapshot { - /** Collateral deposited into the venue. */ - balance: bigint; - /** Maintenance margin required across all open positions. */ - maintenanceMargin: bigint; - /** Non-deposited wallet balance of the collateral token (can be deposited). */ - walletTokenBalance: bigint; - /** Native gas token balance (for paying gas). */ - nativeBalance: bigint; - /** Address of the collateral ERC-20 token. */ - collateralTokenAddress: `0x${string}`; -} - -/** Venue-specific hint data that pricing strategies can consume. */ -export interface InstrumentContext { - /** Unix seconds of delivery / expiry (optional). */ - deliveryDate?: number; - /** Contract multiplier (e.g. futures days-to-delivery). */ - contractMultiplier?: bigint; - /** Strike price (options). */ - strike?: bigint; - /** Call vs put (options). */ - isCall?: boolean; - /** Underlying spot (options). */ - underlyingSpot?: bigint; -} - -export type VenueEvent = - | { type: "order-created"; order: OwnOrder; isOwn: boolean } - | { type: "order-cancelled"; orderId: `0x${string}`; isOwn: boolean; instrumentId?: string } - | { type: "order-updated"; orderId: `0x${string}`; newQuantity: bigint; isOwn: boolean; instrumentId?: string } - | { type: "order-matched"; makerOrderId: `0x${string}`; isOwn: boolean; instrumentId?: string } - | { type: "position-changed"; instrumentId?: string } - | { type: "depth-changed"; price: bigint; isBid: boolean; newQuantity: bigint; instrumentId?: string }; - -export type Unsubscribe = () => void; - -/** - * Per-instrument interface. Perps/futures return a singleton; options returns one - * per strike/expiry. - */ -export interface InstrumentAdapter { - readonly id: string; - readonly venue: VenueAdapter; - - getIndexPrice(): Promise; - getMinTick(): Promise; - getOwnOrders(): Promise; - getPosition(): Promise; - getContext(): Promise; - /** Snapshot of the resting order book (for full resync). `depth` caps levels per side. */ - getOrderBookSnapshot(opts?: { depth?: number }): Promise; - - buildCancelCalldata(orderId: `0x${string}`): `0x${string}`; - buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}`; - - /** - * Estimate gas for a representative createOrder, used by GasTracker.calibrate. - * Returns 0n on failure. - */ - estimateCreateGas(account: `0x${string}`): Promise; -} - -/** - * Per-venue interface. One per deployed process (a process today has exactly one venue, - * but that may grow). Owns the wallet, collateral, multicall and events. - */ -export interface VenueAdapter { - readonly kind: VenueKind; - readonly wallet: WalletContext; - readonly publicClient: PublicClient; - readonly chain: Chain; - readonly transport: Transport; - - /** Contract address used for tx target and events subscription. */ - readonly address: `0x${string}`; - - listInstruments(): Promise; - getCollateral(): Promise; - topUpCollateral(amount: bigint): Promise; - - /** Batch cancels/creates across one or more instruments. Returns tx hash. */ - multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`>; - - /** - * Subscribe to venue-level events affecting the MM's own orders/positions. - * Implementations should filter to the MM's wallet address internally. - */ - subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe; - - /** - * Optional: initial bootstrap read from contract history (past events). Used to - * seed BookTracker/InventoryManager before live events start flowing. - * Implementations that don't need this can no-op. - */ - bootstrapFromHistory?(opts: { fromBlock?: bigint }): Promise; -} diff --git a/market-maker/src/adapters/futures/abi.ts b/market-maker/src/adapters/futures/abi.ts deleted file mode 100644 index 9c7ea76..0000000 --- a/market-maker/src/adapters/futures/abi.ts +++ /dev/null @@ -1,221 +0,0 @@ -export const futuresAbi = [ - { - inputs: [], - name: "DeliveryDateExpired", - type: "error", - }, - { - inputs: [], - name: "DeliveryDateNotAvailable", - type: "error", - }, - { - inputs: [], - name: "DeliveryDateShouldBeInTheFuture", - type: "error", - }, - { - inputs: [], - name: "InsufficientMarginBalance", - type: "error", - }, - { - inputs: [], - name: "InvalidPrice", - type: "error", - }, - { - inputs: [], - name: "InvalidQty", - type: "error", - }, - { - inputs: [], - name: "MaxOrdersPerParticipantReached", - type: "error", - }, - { - inputs: [], - name: "OrderNotBelongToSender", - type: "error", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "bytes32", name: "orderId", type: "bytes32" }, - { indexed: true, internalType: "address", name: "participant", type: "address" }, - ], - name: "OrderClosed", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "bytes32", name: "orderId", type: "bytes32" }, - { indexed: true, internalType: "address", name: "participant", type: "address" }, - { indexed: false, internalType: "string", name: "destURL", type: "string" }, - { indexed: false, internalType: "uint256", name: "pricePerDay", type: "uint256" }, - { indexed: false, internalType: "uint256", name: "deliveryAt", type: "uint256" }, - { indexed: false, internalType: "bool", name: "isBuy", type: "bool" }, - ], - name: "OrderCreated", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, - ], - name: "PositionClosed", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, - { indexed: true, internalType: "address", name: "seller", type: "address" }, - { indexed: true, internalType: "address", name: "buyer", type: "address" }, - { indexed: false, internalType: "uint256", name: "sellPricePerDay", type: "uint256" }, - { indexed: false, internalType: "uint256", name: "buyPricePerDay", type: "uint256" }, - { indexed: false, internalType: "uint256", name: "deliveryAt", type: "uint256" }, - { indexed: false, internalType: "string", name: "destURL", type: "string" }, - { indexed: false, internalType: "bytes32", name: "orderId", type: "bytes32" }, - ], - name: "PositionCreated", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "bytes32", name: "positionId", type: "bytes32" }, - { indexed: true, internalType: "address", name: "closedBy", type: "address" }, - ], - name: "PositionDeliveryClosed", - type: "event", - }, - { - inputs: [ - { internalType: "uint256", name: "_amount", type: "uint256" }, - ], - name: "addMargin", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "account", type: "address" }, - ], - name: "balanceOf", - outputs: [{ internalType: "uint256", name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "bytes32", name: "_orderId", type: "bytes32" }, - ], - name: "closeOrder", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "uint256", name: "_price", type: "uint256" }, - { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, - { internalType: "string", name: "_destURL", type: "string" }, - { internalType: "int8", name: "_qty", type: "int8" }, - ], - name: "createOrder", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [], - name: "deliveryDurationDays", - outputs: [{ internalType: "uint8", name: "", type: "uint8" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "getDeliveryDates", - 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: "address", name: "_participant", type: "address" }, - ], - name: "getMinMargin", - outputs: [{ internalType: "int256", name: "", type: "int256" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "bytes32", name: "_orderId", type: "bytes32" }, - ], - name: "getOrderById", - outputs: [ - { - components: [ - { internalType: "bool", name: "isBuy", type: "bool" }, - { internalType: "address", name: "participant", type: "address" }, - { internalType: "string", name: "destURL", type: "string" }, - { internalType: "uint256", name: "pricePerDay", type: "uint256" }, - { internalType: "uint256", name: "deliveryAt", type: "uint256" }, - { internalType: "uint256", name: "createdAt", type: "uint256" }, - ], - internalType: "struct Futures.Order", - name: "", - type: "tuple", - }, - ], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "_participant", type: "address" }, - { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, - ], - name: "getPositionsByParticipantDeliveryDate", - outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "minimumPriceIncrement", - outputs: [{ internalType: "uint256", name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "bytes[]", name: "data", type: "bytes[]" }, - ], - name: "multicall", - outputs: [{ internalType: "bytes[]", name: "results", type: "bytes[]" }], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [], - name: "token", - outputs: [{ internalType: "contract IERC20", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, -] as const; diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts new file mode 100644 index 0000000..3443a59 --- /dev/null +++ b/market-maker/src/adapters/futures/events.ts @@ -0,0 +1,87 @@ +import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; +import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; +import { FuturesAbi } from "../../abi/Futures.ts"; + +export const FUTURES_INSTRUMENT_ID = "futures"; + +type FuturesLog = Log; + +/** Multiplexes one viem watcher across many subscribers. Decode-only. */ +export class FuturesVenueEvents implements VenueEvents { + private listeners = new Set<(event: VenueEvent) => void>(); + private unwatch: WatchContractEventReturnType | null = null; + + constructor( + private readonly publicClient: PublicClient, + private readonly address: `0x${string}`, + ) {} + + subscribe(cb: (event: VenueEvent) => void): Unsubscribe { + this.listeners.add(cb); + if (this.unwatch === null) this.attachWatcher(); + return () => { + this.listeners.delete(cb); + if (this.listeners.size === 0) this.detachWatcher(); + }; + } + + private attachWatcher(): void { + this.unwatch = this.publicClient.watchContractEvent({ + address: this.address, + abi: FuturesAbi, + onLogs: (logs) => { + for (const log of logs) { + const evt = decodeEvent(log as FuturesLog); + if (evt) for (const l of this.listeners) l(evt); + } + }, + }); + } + + private detachWatcher(): void { + this.unwatch?.(); + this.unwatch = null; + } +} + +export function decodeEvent(log: FuturesLog): VenueEvent | null { + switch (log.eventName) { + case "OrderCreated": { + const { orderId, participant, pricePerDay, isBuy } = log.args; + if (!orderId || !participant || pricePerDay === undefined || isBuy === undefined) return null; + return { + type: "order-created", + orderId, + participant, + price: pricePerDay, + side: isBuy ? "buy" : "sell", + size: 1n, // futures orders are always single-contract per OrderCreated event + instrumentId: FUTURES_INSTRUMENT_ID, + }; + } + case "OrderClosed": { + const { orderId, participant } = log.args; + if (!orderId || !participant) return null; + return { + type: "order-cancelled", + orderId, + participant, + instrumentId: FUTURES_INSTRUMENT_ID, + }; + } + case "PositionCreated": { + const { seller, buyer } = log.args; + if (!seller || !buyer) return null; + // Emit a position-changed for both sides; consumers filter by participant. + return { + type: "position-changed", + participant: seller, + instrumentId: FUTURES_INSTRUMENT_ID, + }; + } + case "PositionClosed": + return { type: "position-changed", participant: "0x0" as `0x${string}`, instrumentId: FUTURES_INSTRUMENT_ID }; + default: + return null; + } +} diff --git a/market-maker/src/adapters/futures/factory.ts b/market-maker/src/adapters/futures/factory.ts deleted file mode 100644 index 55893e2..0000000 --- a/market-maker/src/adapters/futures/factory.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { encodeFunctionData, erc20Abi } from "viem"; -import type { Log, WatchContractEventReturnType } from "viem"; -import type pino from "pino"; -import { - type AdapterFactoryContext, - type CollateralSnapshot, - type InstrumentAdapter, - type InstrumentContext, - type OrderBookSnapshot, - type OwnOrder, - type Position, - type Unsubscribe, - type VenueAdapter, - type VenueEvent, - type WalletContext, - registerAdapter, -} from "../../index.ts"; -import { futuresAbi } from "./abi.ts"; - -const FUTURES_INSTRUMENT_ID = "futures"; - -interface FuturesVenueOptions { - ctx: AdapterFactoryContext; - address: `0x${string}`; - wallet: WalletContext; - logger: pino.Logger; -} - -/** - * Tracks a matched position so we can reverse it when it's closed. - * Each PositionCreated event represents one unit (qty = ±1). - */ -interface TrackedPosition { - isBuy: boolean; - price: bigint; -} - -type FuturesLog = Log; - -export class FuturesVenueAdapter implements VenueAdapter { - readonly kind = "futures" as const; - readonly wallet: WalletContext; - readonly publicClient: AdapterFactoryContext["network"]["publicClient"]; - readonly chain: AdapterFactoryContext["network"]["chain"]; - readonly transport: AdapterFactoryContext["network"]["transport"]; - readonly address: `0x${string}`; - - private readonly logger: pino.Logger; - private collateralTokenCache: `0x${string}` | null = null; - - /** Open own orders, keyed by orderId. Updated by events + bootstrapFromHistory. */ - readonly ownOrders = new Map<`0x${string}`, OwnOrder>(); - - /** Open positions we are party to, keyed by positionId. */ - private readonly openPositions = new Map<`0x${string}`, TrackedPosition>(); - - /** Net signed quantity across all open positions (+long, -short). */ - netQuantity = 0n; - - /** Average price across open positions (rough P&L reference). */ - entryPrice = 0n; - - constructor(opts: FuturesVenueOptions) { - this.wallet = opts.wallet; - this.publicClient = opts.ctx.network.publicClient; - this.chain = opts.ctx.network.chain; - this.transport = opts.ctx.network.transport; - this.address = opts.address; - this.logger = opts.logger.child({ component: "futures-venue" }); - } - - async listInstruments(): Promise { - return [new FuturesInstrumentAdapter(this)]; - } - - async getCollateralTokenAddress(): Promise<`0x${string}`> { - if (this.collateralTokenCache) return this.collateralTokenCache; - this.collateralTokenCache = await this.publicClient.readContract({ - address: this.address, - abi: futuresAbi, - functionName: "token", - }); - return this.collateralTokenCache; - } - - async getCollateral(): Promise { - const collateralTokenAddress = await this.getCollateralTokenAddress(); - const owner = this.wallet.account.address; - - const [balance, walletTokenBalance, minMarginSigned, nativeBalance] = await Promise.all([ - this.publicClient.readContract({ - address: this.address, - abi: futuresAbi, - functionName: "balanceOf", - args: [owner], - }), - this.publicClient.readContract({ - address: collateralTokenAddress, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }), - this.publicClient.readContract({ - address: this.address, - abi: futuresAbi, - functionName: "getMinMargin", - args: [owner], - }), - this.publicClient.getBalance({ address: owner }), - ]); - - return { - balance, - walletTokenBalance, - maintenanceMargin: minMarginSigned < 0n ? 0n : minMarginSigned, - nativeBalance, - collateralTokenAddress, - }; - } - - async topUpCollateral(amount: bigint): Promise { - if (amount <= 0n) return; - const collateralTokenAddress = await this.getCollateralTokenAddress(); - - this.logger.info({ amount: amount.toString() }, "approving collateral token"); - const approveHash = await this.wallet.walletClient.writeContract({ - address: collateralTokenAddress, - abi: erc20Abi, - functionName: "approve", - args: [this.address, amount], - account: this.wallet.account, - chain: this.chain, - }); - await this.publicClient.waitForTransactionReceipt({ hash: approveHash }); - - this.logger.info({ amount: amount.toString() }, "adding margin to futures contract"); - const marginHash = await this.wallet.walletClient.writeContract({ - address: this.address, - abi: futuresAbi, - functionName: "addMargin", - args: [amount], - account: this.wallet.account, - chain: this.chain, - }); - await this.publicClient.waitForTransactionReceipt({ hash: marginHash }); - } - - async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { - 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, - }); - } - - subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe { - const ownAddress = this.wallet.account.address.toLowerCase(); - const unwatch: WatchContractEventReturnType = this.publicClient.watchContractEvent({ - address: this.address, - abi: futuresAbi, - onLogs: (logs) => { - for (const log of logs) { - const evt = this._decodeAndApply(log as FuturesLog, ownAddress); - if (evt) handler(evt); - } - }, - }); - return () => unwatch(); - } - - /** - * Seed in-memory order/position state from on-chain event history. - * Called once at startup, before live event subscription begins. - * If fromBlock is omitted, scans the last 1000 blocks. - */ - async bootstrapFromHistory(opts: { fromBlock?: bigint } = {}): Promise { - const owner = this.wallet.account.address; - const fromBlock = opts.fromBlock ?? (await this._defaultFromBlock()); - - this.logger.info({ fromBlock: fromBlock.toString() }, "bootstrapping futures state from history"); - - // Seed own orders from OrderCreated / OrderClosed events. - const [createdLogs, closedLogs] = await Promise.all([ - this.publicClient.getContractEvents({ - address: this.address, - abi: futuresAbi, - eventName: "OrderCreated", - args: { participant: owner }, - fromBlock, - toBlock: "latest", - }), - this.publicClient.getContractEvents({ - address: this.address, - abi: futuresAbi, - eventName: "OrderClosed", - args: { participant: owner }, - fromBlock, - toBlock: "latest", - }), - ]); - - for (const log of createdLogs) { - const { orderId, pricePerDay, isBuy } = log.args; - if (!orderId || pricePerDay === undefined || isBuy === undefined) continue; - this.ownOrders.set(orderId, { - orderId, - price: pricePerDay, - quantity: isBuy ? 1n : -1n, - instrumentId: FUTURES_INSTRUMENT_ID, - }); - } - for (const log of closedLogs) { - const { orderId } = log.args; - if (orderId) this.ownOrders.delete(orderId); - } - - // Seed positions from PositionCreated events (indexed on buyer and seller separately). - const [buyerPositionLogs, sellerPositionLogs] = await Promise.all([ - this.publicClient.getContractEvents({ - address: this.address, - abi: futuresAbi, - eventName: "PositionCreated", - args: { buyer: owner }, - fromBlock, - toBlock: "latest", - }), - this.publicClient.getContractEvents({ - address: this.address, - abi: futuresAbi, - eventName: "PositionCreated", - args: { seller: owner }, - fromBlock, - toBlock: "latest", - }), - ]); - - for (const log of buyerPositionLogs) { - const { positionId, buyPricePerDay } = log.args; - if (!positionId || buyPricePerDay === undefined) continue; - this.openPositions.set(positionId, { isBuy: true, price: buyPricePerDay }); - this.netQuantity += 1n; - } - for (const log of sellerPositionLogs) { - const { positionId, sellPricePerDay } = log.args; - if (!positionId || sellPricePerDay === undefined) continue; - this.openPositions.set(positionId, { isBuy: false, price: sellPricePerDay }); - this.netQuantity -= 1n; - } - - // Walk PositionClosed events and reverse any positions we were part of. - if (this.openPositions.size > 0) { - const myPositionIds = new Set(this.openPositions.keys()); - const closedPositionLogs = await this.publicClient.getContractEvents({ - address: this.address, - abi: futuresAbi, - eventName: "PositionClosed", - fromBlock, - toBlock: "latest", - }); - for (const log of closedPositionLogs) { - const { positionId } = log.args; - if (!positionId || !myPositionIds.has(positionId)) continue; - const pos = this.openPositions.get(positionId); - if (!pos) continue; - this.netQuantity += pos.isBuy ? -1n : 1n; - this.openPositions.delete(positionId); - } - } - - this._recalcEntryPrice(); - - this.logger.info( - { - orders: this.ownOrders.size, - netQuantity: this.netQuantity.toString(), - openPositions: this.openPositions.size, - }, - "bootstrap complete", - ); - } - - /** Decode a contract event log, update in-memory state, and return a VenueEvent. */ - _decodeAndApply(log: FuturesLog, ownAddressLower: string): VenueEvent | null { - switch (log.eventName) { - case "OrderCreated": { - const { orderId, participant, pricePerDay, isBuy } = log.args; - if (!orderId || !participant || pricePerDay === undefined || isBuy === undefined) return null; - const isOwn = participant.toLowerCase() === ownAddressLower; - const order: OwnOrder = { - orderId, - price: pricePerDay, - quantity: isBuy ? 1n : -1n, - instrumentId: FUTURES_INSTRUMENT_ID, - }; - if (isOwn) this.ownOrders.set(orderId, order); - return { type: "order-created", order, isOwn }; - } - - case "OrderClosed": { - const { orderId, participant } = log.args; - if (!orderId || !participant) return null; - const isOwn = participant.toLowerCase() === ownAddressLower; - if (isOwn) this.ownOrders.delete(orderId); - return { type: "order-cancelled", orderId, isOwn, instrumentId: FUTURES_INSTRUMENT_ID }; - } - - case "PositionCreated": { - const { positionId, seller, buyer, buyPricePerDay, sellPricePerDay } = log.args; - if (!positionId || !seller || !buyer || buyPricePerDay === undefined || sellPricePerDay === undefined) { - return null; - } - const isBuyer = buyer.toLowerCase() === ownAddressLower; - const isSeller = seller.toLowerCase() === ownAddressLower; - if (isBuyer) { - this.openPositions.set(positionId, { isBuy: true, price: buyPricePerDay }); - this.netQuantity += 1n; - this._recalcEntryPrice(); - } else if (isSeller) { - this.openPositions.set(positionId, { isBuy: false, price: sellPricePerDay }); - this.netQuantity -= 1n; - this._recalcEntryPrice(); - } - return isBuyer || isSeller - ? { type: "position-changed", instrumentId: FUTURES_INSTRUMENT_ID } - : null; - } - - case "PositionClosed": { - const { positionId } = log.args; - if (!positionId) return null; - const pos = this.openPositions.get(positionId); - if (!pos) return null; - this.netQuantity += pos.isBuy ? -1n : 1n; - this.openPositions.delete(positionId); - this._recalcEntryPrice(); - return { type: "position-changed", instrumentId: FUTURES_INSTRUMENT_ID }; - } - - default: - return null; - } - } - - private async _defaultFromBlock(): Promise { - const latest = await this.publicClient.getBlockNumber(); - return latest > 1000n ? latest - 1000n : 0n; - } - - private _recalcEntryPrice(): void { - if (this.openPositions.size === 0) { - this.entryPrice = 0n; - return; - } - let sum = 0n; - for (const { price } of this.openPositions.values()) { - sum += price; - } - this.entryPrice = sum / BigInt(this.openPositions.size); - } -} - -class FuturesInstrumentAdapter implements InstrumentAdapter { - readonly id = FUTURES_INSTRUMENT_ID; - readonly venue: FuturesVenueAdapter; - - /** - * Nearest delivery date, cached from the last getContext() call. - * Must be populated before buildCreateCalldata() is called. - * getContext() is invoked by Quoter.initialize() before any order building. - */ - private cachedDeliveryDate: bigint | null = null; - - constructor(venue: FuturesVenueAdapter) { - this.venue = venue; - } - - async getIndexPrice(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: futuresAbi, - functionName: "getMarketPrice", - }); - } - - async getMinTick(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: futuresAbi, - functionName: "minimumPriceIncrement", - }); - } - - async getOwnOrders(): Promise { - return Array.from(this.venue.ownOrders.values()); - } - - async getPosition(): Promise { - return { - netQuantity: this.venue.netQuantity, - entryPrice: this.venue.entryPrice, - }; - } - - async getContext(): Promise { - const [deliveryDates, durationDays] = await Promise.all([ - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: futuresAbi, - functionName: "getDeliveryDates", - }), - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: futuresAbi, - functionName: "deliveryDurationDays", - }), - ]); - - if (deliveryDates.length === 0) { - throw new Error("futures contract returned no delivery dates"); - } - - this.cachedDeliveryDate = deliveryDates[0]; - - return { - deliveryDate: Number(deliveryDates[0]), - contractMultiplier: BigInt(durationDays), - }; - } - - async getOrderBookSnapshot(_opts?: { depth?: number }): Promise { - // The futures contract exposes no on-chain order book depth query. - // The BookTracker will rely on live events for best bid/ask tracking. - return { bids: [], asks: [] }; - } - - buildCancelCalldata(orderId: `0x${string}`): `0x${string}` { - return encodeFunctionData({ - abi: futuresAbi, - functionName: "closeOrder", - args: [orderId], - }); - } - - /** - * Encode a createOrder calldata. - * Uses the nearest delivery date cached from the last getContext() call. - * Throws if getContext() has not been called yet (enforced by Quoter.initialize()). - * quantity is a signed integer (positive = buy, negative = sell). - */ - buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}` { - if (this.cachedDeliveryDate === null) { - throw new Error( - "FuturesInstrumentAdapter.getContext() must be called before buildCreateCalldata()", - ); - } - const qty = Number(quantity); - if (qty < -128 || qty > 127) { - throw new Error(`Futures quantity ${qty} out of int8 range`); - } - return encodeFunctionData({ - abi: futuresAbi, - functionName: "createOrder", - args: [price, this.cachedDeliveryDate, "", qty as number & { readonly __int8__: true }], - }); - } - - async estimateCreateGas(account: `0x${string}`): Promise { - if (this.cachedDeliveryDate === null) return 0n; - try { - return await this.venue.publicClient.estimateContractGas({ - address: this.venue.address, - abi: futuresAbi, - functionName: "createOrder", - args: [1_000_000n, this.cachedDeliveryDate, "", 1], - account, - }); - } catch { - return 0n; - } - } -} - -/** Register the futures adapter factory. Idempotent. */ -let registered = false; -export function registerFuturesAdapter(): void { - if (registered) return; - registered = true; - registerAdapter("futures", async (ctx) => { - const wallet = ctx.wallets.get(ctx.config.venue.wallet); - const adapter = new FuturesVenueAdapter({ - ctx, - address: ctx.config.venue.address, - wallet, - logger: ctx.logger, - }); - await adapter.bootstrapFromHistory({ - fromBlock: - ctx.config.venue.eventsFromBlock !== undefined - ? BigInt(ctx.config.venue.eventsFromBlock) - : undefined, - }); - return adapter; - }); -} diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index 0bfde4c..da07fa9 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -1,5 +1,24 @@ -export * from "./factory.ts"; -export * from "./abi.ts"; +import type pino from "pino"; +import type { NetworkClients } from "../../core/client.ts"; +import type { VenueAdapter, WalletContext } from "../../core/adapter.ts"; +import { FuturesVenueAdapter } from "./venue.ts"; -import { registerFuturesAdapter } from "./factory.ts"; -registerFuturesAdapter(); +export interface CreateFuturesVenueOpts { + network: NetworkClients; + wallet: WalletContext; + address: `0x${string}`; + multicall3Address?: `0x${string}`; + logger: pino.Logger; +} + +/** + * Construct a futures venue adapter. Static wiring — no registry lookup. + * + * matchingMode = "exact" — fills only happen when prices coincide exactly. + */ +export async function createFuturesVenue(opts: CreateFuturesVenueOpts): Promise { + return new FuturesVenueAdapter(opts); +} + +export { FuturesVenueAdapter } from "./venue.ts"; +export { FuturesInstrumentAdapter } from "./instrument.ts"; diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts new file mode 100644 index 0000000..710838a --- /dev/null +++ b/market-maker/src/adapters/futures/instrument.ts @@ -0,0 +1,216 @@ +import { encodeFunctionData } from "viem"; +import type pino from "pino"; +import type { + BookSource, + CancelIntent, + DepthLevel, + InstrumentAdapter, + InstrumentContext, + MatchingMode, + OrderBookSnapshot, + OrderIntent, + Position, +} from "../../core/adapter.ts"; +import { FuturesAbi } from "../../abi/Futures.ts"; +import { FuturesMmExtensionsAbi } from "../../abi/FuturesMmExtensions.ts"; +import type { FuturesVenueAdapter } from "./venue.ts"; +import { FuturesOwnOrders } from "./ownOrders.ts"; + +const FUTURES_INSTRUMENT_ID = "futures"; + +export class FuturesInstrumentAdapter implements InstrumentAdapter { + readonly id = FUTURES_INSTRUMENT_ID; + readonly venue: FuturesVenueAdapter; + readonly book: FuturesBook; + readonly ownOrders: FuturesOwnOrders; + + private tickCache: bigint | null = null; + private deliveryDateCache: bigint | null = null; + private deliveryDurationDaysCache: bigint | null = null; + + constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { + this.venue = venue; + this.book = new FuturesBook(this); + this.ownOrders = new FuturesOwnOrders(venue, logger); + } + + async getIndexPrice(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getMarketPrice", + }); + } + + async getPosition(): Promise { + // Futures' net position is summed from all open positions. We use the + // engine view exposed for this purpose: getNetPositionDelta returns + // `Σ qty_i * deliveryDurationDays` × 1e18 in WAD. Convert back to + // contracts by dividing by `deliveryDurationDays * 1e18`. + const [netDeltaWad, durationDays, marketPrice] = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.venue.address, abi: FuturesAbi, functionName: "getNetPositionDelta", args: [this.venue.wallet.account.address] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "deliveryDurationDays" }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getMarketPrice" }, + ], + }); + const days = BigInt(durationDays); + const denom = days * 10n ** 18n; + const netQuantity = denom === 0n ? 0n : netDeltaWad / denom; + return { netQuantity, entryPrice: marketPrice }; + } + + async getContext(): Promise { + const deliveryDates = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getDeliveryDates", + }); + if (deliveryDates.length === 0) throw new Error("futures contract returned no delivery dates"); + this.deliveryDateCache = deliveryDates[0]; + + // Eagerly cache margin inputs so `estimateOrderMargin` can be synchronous. + const { deliveryDurationDays } = await this.venue.getMarginInputs(); + this.deliveryDurationDaysCache = deliveryDurationDays; + + return { + deliveryDate: Number(deliveryDates[0]), + contractMultiplier: deliveryDurationDays, + }; + } + + encodeCreate(intent: OrderIntent): `0x${string}` { + if (this.deliveryDateCache === null) { + throw new Error("futures: getContext() must be called before encodeCreate()"); + } + const qty = Number(intent.size); + if (qty <= 0 || qty > 127) { + throw new Error(`futures: order size ${qty} must be in (0, 127]`); + } + const signed = (intent.side === "buy" ? qty : -qty) as number & { readonly __int8__: true }; + return encodeFunctionData({ + abi: FuturesAbi, + functionName: "createOrder", + args: [intent.price, this.deliveryDateCache, "", signed], + }); + } + + encodeCancel(intent: CancelIntent): `0x${string}` { + return encodeFunctionData({ + abi: FuturesMmExtensionsAbi, + functionName: "closeOrder", + args: [intent.orderId], + }); + } + + /** + * Mirrors `Futures.getMaintenanceMarginForPosition` for a single new order: + * IM_added = pricePerDay × deliveryDurationDays × |qty| × marginPct / 100 + * + * `getFuturesOrderMargin` clamps each order's marginal contribution at 0 + * when its mark-to-market PnL exceeds maintenance (a profitable order + * locks no extra margin). We don't mirror that branch here: it would + * make the estimate sign-dependent on the live oracle, and the + * conservative "always charge full maintenance" estimate is fine because + * `engine.canPlaceOrder` is the real authority. We err on the high side + * by O(few percent), which only costs us a tiny slice of quoting capacity. + */ + estimateOrderMargin(intent: OrderIntent): bigint { + if (this.deliveryDurationDaysCache === null) return 0n; + // marginPct is loaded lazily at first canPlace call; if we don't have it + // yet, return 0 and let the engine gate sort it out on the first tx. + const cachedMarginPct = (this.venue as unknown as { marginPercentCache?: bigint }).marginPercentCache; + if (!cachedMarginPct) return 0n; + return (intent.price * this.deliveryDurationDaysCache * intent.size * cachedMarginPct) / 100n; + } + + async estimateCreateGas(account: `0x${string}`): Promise { + if (this.deliveryDateCache === null) return 0n; + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "createOrder", + args: [1_000_000n, this.deliveryDateCache, "", 1 as number & { readonly __int8__: true }], + account, + }); + } catch { + return 0n; + } + } + + async getMinTick(): Promise { + if (this.tickCache !== null) return this.tickCache; + const tick = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "minimumPriceIncrement", + }); + this.tickCache = tick; + return tick; + } + + /** Internal: nearest delivery date, populated after `getContext()`. */ + getDeliveryDate(): bigint | null { + return this.deliveryDateCache; + } +} + +/** + * Per-(deliveryDate) book source. The MM is locked to the nearest delivery + * date — see venue header for rationale. + */ +class FuturesBook implements BookSource { + readonly matchingMode: MatchingMode = "exact"; + constructor(private readonly inst: FuturesInstrumentAdapter) {} + + async tick(): Promise { + return this.inst.getMinTick(); + } + + async snapshot(opts: { depth?: number } = {}): Promise { + const v = this.inst.venue; + const dd = this.inst.getDeliveryDate(); + if (dd === null) { + throw new Error("futures: getContext() must be called before book.snapshot()"); + } + const depth = BigInt(opts.depth ?? 200); + + const [bidPrices, askPrices] = await v.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: v.address, abi: FuturesMmExtensionsAbi, functionName: "getBidPrices", args: [dd, depth] }, + { address: v.address, abi: FuturesMmExtensionsAbi, functionName: "getAskPrices", args: [dd, depth] }, + ], + }); + + if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; + + const calls = [ + ...bidPrices.map((p) => ({ + address: v.address, + abi: FuturesMmExtensionsAbi, + functionName: "getQuantityAtPrice" as const, + args: [dd, p, true] as const, + })), + ...askPrices.map((p) => ({ + address: v.address, + abi: FuturesMmExtensionsAbi, + functionName: "getQuantityAtPrice" as const, + args: [dd, p, false] as const, + })), + ]; + const results = await v.publicClient.multicall({ allowFailure: false, contracts: calls }); + + const bidsRaw: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); + const asksRaw: DepthLevel[] = askPrices.map((p, i) => ({ + price: p, + quantity: results[bidPrices.length + i], + })); + // EnumerableSet returns prices in unspecified order; sort for the consumer. + const bids = bidsRaw.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); + const asks = asksRaw.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); + return { bids, asks }; + } +} diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts new file mode 100644 index 0000000..8fee1b1 --- /dev/null +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -0,0 +1,133 @@ +import type pino from "pino"; +import type { + OwnOrder, + OwnOrderEvent, + OwnOrderSource, + Unsubscribe, +} from "../../core/adapter.ts"; +import { FuturesAbi } from "../../abi/Futures.ts"; +import { FuturesMmExtensionsAbi } from "../../abi/FuturesMmExtensions.ts"; +import type { FuturesVenueAdapter } from "./venue.ts"; +import { FUTURES_INSTRUMENT_ID } from "./events.ts"; + +/** + * Cache-backed own-order source for futures. + * + * Why a cache? `Futures.sol` previously had no view returning a participant's + * orders. The new `getOrderIds` view (added alongside this adapter) lets us + * skip the historical event-scan path entirely: + * + * 1. `bootstrap()` reads `getOrderIds(wallet)` and `getOrderById(id)` for + * each in one multicall, populating the cache. + * 2. `subscribe()` listens to venue events and applies adds/removes to the + * cache, then forwards the event to the registered callback. + * 3. `list()` returns `Array.from(cache.values())`. + * + * Idempotency: bootstrap clears the cache before re-populating, so calling + * it twice is safe. + */ +export class FuturesOwnOrders implements OwnOrderSource { + private readonly cache = new Map<`0x${string}`, OwnOrder>(); + private readonly listeners = new Set<(event: OwnOrderEvent) => void>(); + private unsubVenue: Unsubscribe | null = null; + private bootstrapped = false; + + private readonly venue: FuturesVenueAdapter; + private readonly logger: pino.Logger; + + constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { + this.venue = venue; + this.logger = logger.child({ component: "futures-own-orders" }); + } + + async list(): Promise { + return Array.from(this.cache.values()); + } + + subscribe(cb: (event: OwnOrderEvent) => void): Unsubscribe { + this.listeners.add(cb); + if (this.unsubVenue === null) this.unsubVenue = this.attach(); + return () => { + this.listeners.delete(cb); + if (this.listeners.size === 0) { + this.unsubVenue?.(); + this.unsubVenue = null; + } + }; + } + + async bootstrap(_opts: { fromBlock?: bigint } = {}): Promise { + const owner = this.venue.wallet.account.address; + this.cache.clear(); + + const orderIds = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesMmExtensionsAbi, + functionName: "getOrderIds", + args: [owner], + }); + + if (orderIds.length === 0) { + this.bootstrapped = true; + this.logger.info({ orders: 0 }, "futures own-orders bootstrapped (empty)"); + return; + } + + const calls = orderIds.map((id) => ({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getOrderById" as const, + args: [id] as const, + })); + const orders = await this.venue.publicClient.multicall({ allowFailure: false, contracts: calls }); + + for (let i = 0; i < orderIds.length; i++) { + const o = orders[i]; + if (!o.participant || o.participant === "0x0000000000000000000000000000000000000000") continue; + this.cache.set(orderIds[i], { + orderId: orderIds[i], + price: o.pricePerDay, + side: o.isBuy ? "buy" : "sell", + size: 1n, + instrumentId: FUTURES_INSTRUMENT_ID, + }); + } + + this.bootstrapped = true; + this.logger.info({ orders: this.cache.size }, "futures own-orders bootstrapped"); + } + + private attach(): Unsubscribe { + const own = this.venue.wallet.account.address.toLowerCase(); + return this.venue.events.subscribe((evt) => { + if (evt.type === "order-created") { + if (evt.participant.toLowerCase() !== own) return; + const order: OwnOrder = { + orderId: evt.orderId, + price: evt.price, + side: evt.side, + size: 1n, + instrumentId: FUTURES_INSTRUMENT_ID, + }; + this.cache.set(evt.orderId, order); + this.notify({ type: "added", orderId: evt.orderId, order }); + return; + } + if (evt.type === "order-cancelled") { + if (evt.participant.toLowerCase() !== own) return; + if (!this.cache.has(evt.orderId)) return; + this.cache.delete(evt.orderId); + this.notify({ type: "removed", orderId: evt.orderId }); + return; + } + }); + } + + private notify(event: OwnOrderEvent): void { + for (const cb of this.listeners) cb(event); + } + + isBootstrapped(): boolean { + return this.bootstrapped; + } +} diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts new file mode 100644 index 0000000..50968de --- /dev/null +++ b/market-maker/src/adapters/futures/venue.ts @@ -0,0 +1,242 @@ +import { erc20Abi } from "viem"; +import type { Chain, PublicClient, Transport } from "viem"; +import type pino from "pino"; +import type { + CollateralAccount, + CollateralSnapshot, + InstrumentAdapter, + VenueAdapter, + VenueEvents, + WalletContext, +} from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.ts"; +import { FuturesAbi } from "../../abi/Futures.ts"; +import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; +import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; +import { Multicall3Abi } from "../../abi/Multicall3.ts"; +import { depositToVault } from "../../core/vaultDeposit.ts"; +import { FuturesInstrumentAdapter } from "./instrument.ts"; +import { FuturesVenueEvents } from "./events.ts"; + +export interface FuturesVenueOptions { + network: NetworkClients; + wallet: WalletContext; + address: `0x${string}`; + multicall3Address?: `0x${string}`; + logger: pino.Logger; +} + +/** + * Futures venue: Futures contract, the shared CollateralVault, the + * PortfolioMarginEngine. + * + * The futures contract exposes both `collateralVault` (the vault) and + * `marginEngine` (the engine) on chain; we read both in one multicall. + * + * Single-instrument: `getInstrument()` returns the futures order book for + * the **nearest** delivery date. Multi-delivery support could be layered + * on later by exposing one InstrumentAdapter per delivery date and walking + * them in a portfolio runner; this MVP locks the MM to the nearest date, + * which is where the bulk of liquidity lives. + */ +export class FuturesVenueAdapter implements VenueAdapter { + readonly kind = "futures" as const; + readonly wallet: WalletContext; + readonly publicClient: PublicClient; + readonly chain: Chain; + readonly transport: Transport; + readonly address: `0x${string}`; + + readonly events: VenueEvents; + readonly account: CollateralAccount; + + private readonly logger: pino.Logger; + private readonly multicall3Address: `0x${string}`; + private instrumentSingleton: FuturesInstrumentAdapter | null = null; + + private vaultAddressCache: `0x${string}` | null = null; + private engineAddressCache: `0x${string}` | null = null; + private collateralTokenCache: `0x${string}` | null = null; + private deliveryDurationDaysCache: bigint | null = null; + private marginPercentCache: bigint | null = null; + + constructor(opts: FuturesVenueOptions) { + this.wallet = opts.wallet; + this.publicClient = opts.network.publicClient; + this.chain = opts.network.chain; + this.transport = opts.network.transport; + this.address = opts.address; + this.logger = opts.logger.child({ component: "futures-venue" }); + + const mc3 = opts.multicall3Address ?? (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); + if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); + this.multicall3Address = mc3; + + this.events = new FuturesVenueEvents(this.publicClient, this.address); + this.account = new FuturesCollateralAccount(this); + } + + async getInstrument(): Promise { + if (!this.instrumentSingleton) { + this.instrumentSingleton = new FuturesInstrumentAdapter(this, this.logger); + } + return this.instrumentSingleton; + } + + async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { + 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, + }); + } + + // ── Internal helpers ──────────────────────────────────────────────────── + + async resolveAddresses(): Promise<{ vault: `0x${string}`; engine: `0x${string}`; token: `0x${string}` }> { + if (this.vaultAddressCache && this.engineAddressCache && this.collateralTokenCache) { + return { + vault: this.vaultAddressCache, + engine: this.engineAddressCache, + token: this.collateralTokenCache, + }; + } + const [vault, engine] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.address, abi: FuturesAbi, functionName: "collateralVault" }, + { address: this.address, abi: FuturesAbi, functionName: "marginEngine" }, + ], + }); + const token = await this.publicClient.readContract({ + address: vault, + abi: CollateralVaultAbi, + functionName: "collateralToken", + }); + this.vaultAddressCache = vault; + this.engineAddressCache = engine; + this.collateralTokenCache = token; + return { vault, engine, token }; + } + + getMulticall3Address(): `0x${string}` { + return this.multicall3Address; + } + + getLogger(): pino.Logger { + return this.logger; + } + + /** + * Cache delivery-duration-days and marginPercent on the venue. Both are + * static-ish (admin-changeable) so we read them once and reuse for the + * `estimateOrderMargin` formula. + */ + async getMarginInputs(): Promise<{ deliveryDurationDays: bigint; marginPct: bigint }> { + if (this.deliveryDurationDaysCache !== null && this.marginPercentCache !== null) { + return { + deliveryDurationDays: this.deliveryDurationDaysCache, + marginPct: this.marginPercentCache, + }; + } + const [duration, liqMarginPct] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.address, abi: FuturesAbi, functionName: "deliveryDurationDays" }, + { address: this.address, abi: FuturesAbi, functionName: "liquidationMarginPercent" }, + ], + }); + // Note: `getMarginPercent` on chain adds a breach-penalty term we don't + // mirror here — we use `liquidationMarginPercent` as a slight over-estimate. + // The on-chain check is the real authority; this is just our pre-trade gate. + this.deliveryDurationDaysCache = BigInt(duration); + this.marginPercentCache = BigInt(liqMarginPct); + return { deliveryDurationDays: this.deliveryDurationDaysCache, marginPct: this.marginPercentCache }; + } +} + +/** + * `CollateralAccount` for the futures venue. + * + * `snapshot()` reads all 5 portfolio signals in one multicall: vault balance, + * portfolio IM/MM, futures order margin (positive resting margin), futures + * unrealized PnL (signed), wallet ERC20 balance, native ETH balance. + */ +class FuturesCollateralAccount implements CollateralAccount { + constructor(private readonly venue: FuturesVenueAdapter) {} + + async snapshot(): Promise { + const owner = this.venue.wallet.account.address; + const { vault, engine, token } = await this.venue.resolveAddresses(); + const mc3 = this.venue.getMulticall3Address(); + + const [ + vaultBalance, + portfolioIM, + portfolioMM, + orderMargin, + unrealizedPnl, + walletTokenBalance, + nativeBalance, + ] = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesOrderMargin", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesUnrealizedPnl", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ], + }); + + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: orderMargin, + venueUnrealizedPnl: unrealizedPnl, + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; + } + + async imSpotShock(): Promise { + // Futures uses pricePerDay × deliveryDurationDays × marginPct/100, not a + // spot-shock model. Returns 0 to signal "not applicable" — adapters don't + // use this directly; estimateOrderMargin reads from getMarginInputs instead. + return 0n; + } + + async deposit(amount: bigint): Promise { + if (amount <= 0n) return; + const { vault, token } = await this.venue.resolveAddresses(); + await depositToVault({ + publicClient: this.venue.publicClient, + walletClient: this.venue.wallet.walletClient, + account: this.venue.wallet.account, + chain: this.venue.chain, + vaultAddress: vault, + collateralToken: token, + amount, + logger: this.venue.getLogger(), + }); + } + + async canPlace(additionalIM: bigint): Promise { + if (additionalIM === 0n) return true; + const { engine } = await this.venue.resolveAddresses(); + return await this.venue.publicClient.readContract({ + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "canPlaceOrder", + args: [this.venue.wallet.account.address, additionalIM], + }); + } +} diff --git a/market-maker/src/adapters/options-stub/index.ts b/market-maker/src/adapters/options-stub/index.ts deleted file mode 100644 index 0694023..0000000 --- a/market-maker/src/adapters/options-stub/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Options venue adapter — architecture placeholder only. - * - * An options venue differs from perps/futures in two ways: - * - * 1. Multi-instrument: VenueAdapter.listInstruments() returns one InstrumentAdapter - * per strike/expiry pair (e.g. "options:BTC-25DEC-50000-C"). The Quoter and - * Executor are already instrument-scoped, so a PortfolioRunner calling them - * per-instrument is all that's needed. - * - * 2. IV-based pricing: InstrumentContext would expose { strike, expiry, isCall, - * underlyingSpot, impliedVol } and a BlackScholesQuoter pricing strategy would - * slot in alongside EffectiveSpreadQuoter / ReservationPriceQuoter. - * - * This stub registers "options" in the adapter registry so that a config file with - * `venue.kind: options` produces a clear "not yet implemented" error rather than - * an opaque "unknown kind" error. - */ - -import type { - CollateralSnapshot, - InstrumentAdapter, - Unsubscribe, - VenueAdapter, - VenueEvent, -} from "../../adapter.ts"; -import { NotImplementedError } from "../../errors.ts"; -import { registerAdapter } from "../../registry.ts"; - -export class OptionsVenueAdapter implements VenueAdapter { - readonly kind = "options" as const; - - get wallet(): never { - throw new NotImplementedError("OptionsVenueAdapter.wallet"); - } - get publicClient(): never { - throw new NotImplementedError("OptionsVenueAdapter.publicClient"); - } - get chain(): never { - throw new NotImplementedError("OptionsVenueAdapter.chain"); - } - get transport(): never { - throw new NotImplementedError("OptionsVenueAdapter.transport"); - } - get address(): never { - throw new NotImplementedError("OptionsVenueAdapter.address"); - } - - listInstruments(): Promise { - throw new NotImplementedError("OptionsVenueAdapter.listInstruments"); - } - getCollateral(): Promise { - throw new NotImplementedError("OptionsVenueAdapter.getCollateral"); - } - topUpCollateral(_amount: bigint): Promise { - throw new NotImplementedError("OptionsVenueAdapter.topUpCollateral"); - } - multicall(_calls: `0x${string}`[], _opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`> { - throw new NotImplementedError("OptionsVenueAdapter.multicall"); - } - subscribeVenueEvents(_handler: (event: VenueEvent) => void): Unsubscribe { - throw new NotImplementedError("OptionsVenueAdapter.subscribeVenueEvents"); - } -} - -let registered = false; -export function registerOptionsAdapter(): void { - if (registered) return; - registered = true; - registerAdapter("options", async () => { - throw new NotImplementedError( - 'Options adapter is not yet implemented. Set venue.kind to "perps" or "futures".', - ); - }); -} - -registerOptionsAdapter(); diff --git a/market-maker/src/adapters/perps/abi.ts b/market-maker/src/adapters/perps/abi.ts deleted file mode 100644 index 88f4f1e..0000000 --- a/market-maker/src/adapters/perps/abi.ts +++ /dev/null @@ -1,2207 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// AggregatorV3Interface -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const aggregatorV3InterfaceAbi = [ - { - type: 'function', - inputs: [], - name: 'decimals', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'description', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_roundId', internalType: 'uint80', type: 'uint80' }], - name: 'getRoundData', - outputs: [ - { name: 'roundId', internalType: 'uint80', type: 'uint80' }, - { name: 'answer', internalType: 'int256', type: 'int256' }, - { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'latestRoundData', - outputs: [ - { name: 'roundId', internalType: 'uint80', type: 'uint80' }, - { name: 'answer', internalType: 'int256', type: 'int256' }, - { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'version', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// HashPowerPerpsDEX -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const hashPowerPerpsDexAbi = [ - { - type: 'constructor', - inputs: [ - { - name: '_minimumPriceIncrement', - internalType: 'uint256', - type: 'uint256', - }, - ], - stateMutability: 'nonpayable', - }, - { - type: 'error', - inputs: [{ name: 'target', internalType: 'address', type: 'address' }], - name: 'AddressEmptyCode', - }, - { - type: 'error', - inputs: [ - { name: 'implementation', internalType: 'address', type: 'address' }, - ], - name: 'ERC1967InvalidImplementation', - }, - { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, - { - type: 'error', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'allowance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientAllowance', - }, - { - type: 'error', - inputs: [ - { name: 'sender', internalType: 'address', type: 'address' }, - { name: 'balance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientBalance', - }, - { - type: 'error', - inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidApprover', - }, - { - type: 'error', - inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidReceiver', - }, - { - type: 'error', - inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSender', - }, - { - type: 'error', - inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSpender', - }, - { type: 'error', inputs: [], name: 'FailedCall' }, - { type: 'error', inputs: [], name: 'InsufficientCollateral' }, - { type: 'error', inputs: [], name: 'InsufficientMargin' }, - { type: 'error', inputs: [], name: 'InsufficientReservePool' }, - { type: 'error', inputs: [], name: 'InvalidFundingParameters' }, - { type: 'error', inputs: [], name: 'InvalidInitialization' }, - { type: 'error', inputs: [], name: 'InvalidMarginPercent' }, - { type: 'error', inputs: [], name: 'InvalidOracle' }, - { type: 'error', inputs: [], name: 'InvalidPrice' }, - { type: 'error', inputs: [], name: 'InvalidSize' }, - { type: 'error', inputs: [], name: 'MaxOrdersPerParticipantReached' }, - { type: 'error', inputs: [], name: 'MaxPriceLevelsReached' }, - { type: 'error', inputs: [], name: 'NotInitializing' }, - { type: 'error', inputs: [], name: 'NotLiquidatable' }, - { type: 'error', inputs: [], name: 'OracleStale' }, - { type: 'error', inputs: [], name: 'OrderMarginTooLow' }, - { type: 'error', inputs: [], name: 'OrderNotBelongToSender' }, - { - type: 'error', - inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], - name: 'OwnableInvalidOwner', - }, - { - type: 'error', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'OwnableUnauthorizedAccount', - }, - { - type: 'error', - inputs: [{ name: 'token', internalType: 'address', type: 'address' }], - name: 'SafeERC20FailedOperation', - }, - { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, - { - type: 'error', - inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], - name: 'UUPSUnsupportedProxiableUUID', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'owner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'spender', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Approval', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'user', internalType: 'address', type: 'address', indexed: true }, - { - name: 'amount', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'BadDebt', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'user', internalType: 'address', type: 'address', indexed: true }, - { - name: 'amount', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'CollateralAdded', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'user', internalType: 'address', type: 'address', indexed: true }, - { - name: 'amount', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'CollateralRemoved', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'maxBps', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'period', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'FundingParametersUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'user', internalType: 'address', type: 'address', indexed: true }, - { - name: 'amount', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - ], - name: 'FundingSettled', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'fundingRate', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'cumulativeFundingPerUnit', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'timestamp', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'FundingUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'version', - internalType: 'uint64', - type: 'uint64', - indexed: false, - }, - ], - name: 'Initialized', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'newLiquidationFee', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'LiquidationFeeUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'newMaintenanceMarginPercent', - internalType: 'uint8', - type: 'uint8', - indexed: false, - }, - ], - name: 'MaintenanceMarginPercentUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'newMarginPercent', - internalType: 'uint8', - type: 'uint8', - indexed: false, - }, - ], - name: 'MarginPercentUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'newTakerFeeBps', - internalType: 'int16', - type: 'int16', - indexed: false, - }, - { - name: 'newMakerFeeBps', - internalType: 'int16', - type: 'int16', - indexed: false, - }, - ], - name: 'MatchFeeUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'newMinimumMarginPerOrder', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'MinimumMarginPerOrderUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'orderId', - internalType: 'bytes32', - type: 'bytes32', - indexed: true, - }, - { - name: 'participant', - internalType: 'address', - type: 'address', - indexed: true, - }, - ], - name: 'OrderCancelled', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'orderId', - internalType: 'bytes32', - type: 'bytes32', - indexed: true, - }, - { - name: 'participant', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'price', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'quantity', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - ], - name: 'OrderCreated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'makerOrderId', - internalType: 'bytes32', - type: 'bytes32', - indexed: true, - }, - { - name: 'maker', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'taker', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'tradePrice', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'takerQuantity', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'makerFee', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'takerFee', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'makerNetQtyAfter', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'takerNetQtyAfter', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { - name: 'makerEntryPriceAfter', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'takerEntryPriceAfter', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'OrderMatched', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'orderId', - internalType: 'bytes32', - type: 'bytes32', - indexed: true, - }, - { - name: 'participant', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'newQuantity', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - ], - name: 'OrderUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'previousOwner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'newOwner', - internalType: 'address', - type: 'address', - indexed: true, - }, - ], - name: 'OwnershipTransferred', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'user', internalType: 'address', type: 'address', indexed: true }, - { - name: 'liquidator', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'positionSize', - internalType: 'int256', - type: 'int256', - indexed: false, - }, - { name: 'pnl', internalType: 'int256', type: 'int256', indexed: false }, - { - name: 'liquidatorFee', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'PositionLiquidated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'from', internalType: 'address', type: 'address', indexed: true }, - { name: 'to', internalType: 'address', type: 'address', indexed: true }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Transfer', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'implementation', - internalType: 'address', - type: 'address', - indexed: true, - }, - ], - name: 'Upgraded', - }, - { - type: 'function', - inputs: [], - name: 'FUNDING_DECIMALS', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'MAX_ORDERS_PER_PARTICIPANT', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'MAX_PRICE_LEVELS_PER_SIDE', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'QUANTITY_DECIMALS', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'UPGRADE_INTERFACE_VERSION', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], - name: 'addCollateral', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: '_amount', internalType: 'uint256', type: 'uint256' }, - { name: '_deadline', internalType: 'uint256', type: 'uint256' }, - { name: '_v', internalType: 'uint8', type: 'uint8' }, - { name: '_r', internalType: 'bytes32', type: 'bytes32' }, - { name: '_s', internalType: 'bytes32', type: 'bytes32' }, - ], - name: 'addCollateralWithPermit', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - ], - name: 'allowance', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'approve', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'balanceOf', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_orderId', internalType: 'bytes32', type: 'bytes32' }], - name: 'cancelOrder', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'collateralToken', - outputs: [{ name: '', internalType: 'contract IERC20', type: 'address' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: '_price', internalType: 'uint256', type: 'uint256' }, - { name: '_quantity', internalType: 'int256', type: 'int256' }, - ], - name: 'createOrder', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'cumulativeFundingPerUnit', - outputs: [{ name: '', internalType: 'int256', type: 'int256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'decimals', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], - name: 'depositReservePool', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'fundingPeriod', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'fundingRateMaxBps', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getBestAskPrice', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getBestBidPrice', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getInitialMargin', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getMaintenanceMargin', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getMarketPrice', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_orderId', internalType: 'bytes32', type: 'bytes32' }], - name: 'getOrder', - outputs: [ - { - name: '', - internalType: 'struct HashPowerPerpsDEX.Order', - type: 'tuple', - components: [ - { name: 'participant', internalType: 'address', type: 'address' }, - { name: 'price', internalType: 'uint256', type: 'uint256' }, - { name: 'quantity', internalType: 'int256', type: 'int256' }, - ], - }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_maxLevels', internalType: 'uint256', type: 'uint256' }], - name: 'getOrderBookPrices', - outputs: [ - { name: 'bidPrices', internalType: 'uint256[]', type: 'uint256[]' }, - { name: 'askPrices', internalType: 'uint256[]', type: 'uint256[]' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getPendingFunding', - outputs: [{ name: '', internalType: 'int256', type: 'int256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: '_price', internalType: 'uint256', type: 'uint256' }, - { name: '_isBid', internalType: 'bool', type: 'bool' }, - ], - name: 'getQuantityAtPrice', - outputs: [ - { name: 'totalQuantity', internalType: 'uint256', type: 'uint256' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getRequiredMargin', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getUnrealizedPnl', - outputs: [{ name: '', internalType: 'int256', type: 'int256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getUserOrders', - outputs: [{ name: '', internalType: 'bytes32[]', type: 'bytes32[]' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'getUserPosition', - outputs: [ - { - name: '', - internalType: 'struct HashPowerPerpsDEX.Position', - type: 'tuple', - components: [ - { name: 'netQuantity', internalType: 'int256', type: 'int256' }, - { - name: 'aggregatedEntryPrice', - internalType: 'uint256', - type: 'uint256', - }, - ], - }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getUsersWithPositions', - outputs: [{ name: '', internalType: 'address[]', type: 'address[]' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { - name: '_collateralToken', - internalType: 'contract IERC20Metadata', - type: 'address', - }, - { - name: '_priceOracle', - internalType: 'contract AggregatorV3Interface', - type: 'address', - }, - { name: '_marginPercent', internalType: 'uint8', type: 'uint8' }, - { - name: '_maintenanceMarginPercent', - internalType: 'uint8', - type: 'uint8', - }, - ], - name: 'initialize', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: '_user', internalType: 'address', type: 'address' }], - name: 'isLiquidatable', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'lastFundingUpdateTime', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_users', internalType: 'address[]', type: 'address[]' }], - name: 'liquidateBatch', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'liquidationFee', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'maintenanceMarginPercent', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'makerFeeBps', - outputs: [{ name: '', internalType: 'int16', type: 'int16' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'marginPercent', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'minimumMarginPerOrder', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'minimumPriceIncrement', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: 'data', internalType: 'bytes[]', type: 'bytes[]' }], - name: 'multicall', - outputs: [{ name: 'results', internalType: 'bytes[]', type: 'bytes[]' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'name', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'owner', - outputs: [{ name: '', internalType: 'address', type: 'address' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'priceOracle', - outputs: [ - { - name: '', - internalType: 'contract AggregatorV3Interface', - type: 'address', - }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'proxiableUUID', - outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], - name: 'removeCollateral', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'renounceOwnership', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'resetState', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: '_fundingRateMaxBps', internalType: 'uint256', type: 'uint256' }, - { name: '_fundingPeriod', internalType: 'uint256', type: 'uint256' }, - ], - name: 'setFundingParameters', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: '_liquidationFee', internalType: 'uint256', type: 'uint256' }, - ], - name: 'setLiquidationFee', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { - name: '_maintenanceMarginPercent', - internalType: 'uint8', - type: 'uint8', - }, - ], - name: 'setMaintenanceMarginPercent', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: '_marginPercent', internalType: 'uint8', type: 'uint8' }], - name: 'setMarginPercent', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: '_takerFeeBps', internalType: 'int16', type: 'int16' }, - { name: '_makerFeeBps', internalType: 'int16', type: 'int16' }, - ], - name: 'setMatchFee', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { - name: '_minimumMarginPerOrder', - internalType: 'uint256', - type: 'uint256', - }, - ], - name: 'setMinimumMarginPerOrder', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { - name: '_oracle', - internalType: 'contract AggregatorV3Interface', - type: 'address', - }, - ], - name: 'setOracle', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: '_price', internalType: 'uint256', type: 'uint256' }, - { name: '_quantity', internalType: 'int256', type: 'int256' }, - ], - name: 'simulateOrder', - outputs: [ - { name: 'filledQuantity', internalType: 'int256', type: 'int256' }, - { name: 'averageFillPrice', internalType: 'uint256', type: 'uint256' }, - { name: 'remainingQuantity', internalType: 'int256', type: 'int256' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'symbol', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'takerFeeBps', - outputs: [{ name: '', internalType: 'int16', type: 'int16' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'totalSupply', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transfer', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'from', internalType: 'address', type: 'address' }, - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transferFrom', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], - name: 'transferOwnership', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'updateFunding', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'newImplementation', internalType: 'address', type: 'address' }, - { name: 'data', internalType: 'bytes', type: 'bytes' }, - ], - name: 'upgradeToAndCall', - outputs: [], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [{ name: '_amount', internalType: 'uint256', type: 'uint256' }], - name: 'withdrawReservePool', - outputs: [], - stateMutability: 'nonpayable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// IERC20 -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const ierc20Abi = [ - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'owner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'spender', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Approval', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'from', internalType: 'address', type: 'address', indexed: true }, - { name: 'to', internalType: 'address', type: 'address', indexed: true }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Transfer', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - ], - name: 'allowance', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'approve', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'balanceOf', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'totalSupply', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transfer', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'from', internalType: 'address', type: 'address' }, - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transferFrom', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// IERC20Permit -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const ierc20PermitAbi = [ - { - type: 'function', - inputs: [], - name: 'DOMAIN_SEPARATOR', - outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], - name: 'nonces', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - { name: 'deadline', internalType: 'uint256', type: 'uint256' }, - { name: 'v', internalType: 'uint8', type: 'uint8' }, - { name: 'r', internalType: 'bytes32', type: 'bytes32' }, - { name: 's', internalType: 'bytes32', type: 'bytes32' }, - ], - name: 'permit', - outputs: [], - stateMutability: 'nonpayable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// IERC5267 -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const ierc5267Abi = [ - { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, - { - type: 'function', - inputs: [], - name: 'eip712Domain', - outputs: [ - { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, - { name: 'name', internalType: 'string', type: 'string' }, - { name: 'version', internalType: 'string', type: 'string' }, - { name: 'chainId', internalType: 'uint256', type: 'uint256' }, - { name: 'verifyingContract', internalType: 'address', type: 'address' }, - { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, - { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, - ], - stateMutability: 'view', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// Multicall3 -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const multicall3Abi = [ - { - type: 'function', - inputs: [ - { - name: 'calls', - internalType: 'struct Multicall3.Call[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'aggregate', - outputs: [ - { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, - { name: 'returnData', internalType: 'bytes[]', type: 'bytes[]' }, - ], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [ - { - name: 'calls', - internalType: 'struct Multicall3.Call3[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'allowFailure', internalType: 'bool', type: 'bool' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'aggregate3', - outputs: [ - { - name: 'returnData', - internalType: 'struct Multicall3.Result[]', - type: 'tuple[]', - components: [ - { name: 'success', internalType: 'bool', type: 'bool' }, - { name: 'returnData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [ - { - name: 'calls', - internalType: 'struct Multicall3.Call3Value[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'allowFailure', internalType: 'bool', type: 'bool' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'aggregate3Value', - outputs: [ - { - name: 'returnData', - internalType: 'struct Multicall3.Result[]', - type: 'tuple[]', - components: [ - { name: 'success', internalType: 'bool', type: 'bool' }, - { name: 'returnData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [ - { - name: 'calls', - internalType: 'struct Multicall3.Call[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'blockAndAggregate', - outputs: [ - { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, - { name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }, - { - name: 'returnData', - internalType: 'struct Multicall3.Result[]', - type: 'tuple[]', - components: [ - { name: 'success', internalType: 'bool', type: 'bool' }, - { name: 'returnData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [], - name: 'getBasefee', - outputs: [{ name: 'basefee', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: 'blockNumber', internalType: 'uint256', type: 'uint256' }], - name: 'getBlockHash', - outputs: [{ name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getBlockNumber', - outputs: [ - { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getChainId', - outputs: [{ name: 'chainid', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getCurrentBlockCoinbase', - outputs: [{ name: 'coinbase', internalType: 'address', type: 'address' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getCurrentBlockDifficulty', - outputs: [{ name: 'difficulty', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getCurrentBlockGasLimit', - outputs: [{ name: 'gaslimit', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getCurrentBlockTimestamp', - outputs: [{ name: 'timestamp', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: 'addr', internalType: 'address', type: 'address' }], - name: 'getEthBalance', - outputs: [{ name: 'balance', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'getLastBlockHash', - outputs: [{ name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'requireSuccess', internalType: 'bool', type: 'bool' }, - { - name: 'calls', - internalType: 'struct Multicall3.Call[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'tryAggregate', - outputs: [ - { - name: 'returnData', - internalType: 'struct Multicall3.Result[]', - type: 'tuple[]', - components: [ - { name: 'success', internalType: 'bool', type: 'bool' }, - { name: 'returnData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - stateMutability: 'payable', - }, - { - type: 'function', - inputs: [ - { name: 'requireSuccess', internalType: 'bool', type: 'bool' }, - { - name: 'calls', - internalType: 'struct Multicall3.Call[]', - type: 'tuple[]', - components: [ - { name: 'target', internalType: 'address', type: 'address' }, - { name: 'callData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - name: 'tryBlockAndAggregate', - outputs: [ - { name: 'blockNumber', internalType: 'uint256', type: 'uint256' }, - { name: 'blockHash', internalType: 'bytes32', type: 'bytes32' }, - { - name: 'returnData', - internalType: 'struct Multicall3.Result[]', - type: 'tuple[]', - components: [ - { name: 'success', internalType: 'bool', type: 'bool' }, - { name: 'returnData', internalType: 'bytes', type: 'bytes' }, - ], - }, - ], - stateMutability: 'payable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// PerpsSimpleDLL -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const perpsSimpleDllAbi = [ - { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, - { - type: 'error', - inputs: [{ name: 'target', internalType: 'address', type: 'address' }], - name: 'AddressEmptyCode', - }, - { - type: 'error', - inputs: [ - { name: 'implementation', internalType: 'address', type: 'address' }, - ], - name: 'ERC1967InvalidImplementation', - }, - { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, - { - type: 'error', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'allowance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientAllowance', - }, - { - type: 'error', - inputs: [ - { name: 'sender', internalType: 'address', type: 'address' }, - { name: 'balance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientBalance', - }, - { - type: 'error', - inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidApprover', - }, - { - type: 'error', - inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidReceiver', - }, - { - type: 'error', - inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSender', - }, - { - type: 'error', - inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSpender', - }, - { type: 'error', inputs: [], name: 'FailedCall' }, - { type: 'error', inputs: [], name: 'InvalidInitialization' }, - { type: 'error', inputs: [], name: 'NotInitializing' }, - { - type: 'error', - inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], - name: 'OwnableInvalidOwner', - }, - { - type: 'error', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'OwnableUnauthorizedAccount', - }, - { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, - { - type: 'error', - inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], - name: 'UUPSUnsupportedProxiableUUID', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'owner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'spender', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Approval', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'version', - internalType: 'uint64', - type: 'uint64', - indexed: false, - }, - ], - name: 'Initialized', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'previousOwner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'newOwner', - internalType: 'address', - type: 'address', - indexed: true, - }, - ], - name: 'OwnershipTransferred', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'from', internalType: 'address', type: 'address', indexed: true }, - { name: 'to', internalType: 'address', type: 'address', indexed: true }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Transfer', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'implementation', - internalType: 'address', - type: 'address', - indexed: true, - }, - ], - name: 'Upgraded', - }, - { - type: 'function', - inputs: [], - name: 'UPGRADE_INTERFACE_VERSION', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - ], - name: 'allowance', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'approve', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'balanceOf', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'decimals', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'name', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'owner', - outputs: [{ name: '', internalType: 'address', type: 'address' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'proxiableUUID', - outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'renounceOwnership', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'symbol', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'totalSupply', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transfer', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'from', internalType: 'address', type: 'address' }, - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transferFrom', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], - name: 'transferOwnership', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'newImplementation', internalType: 'address', type: 'address' }, - { name: 'data', internalType: 'bytes', type: 'bytes' }, - ], - name: 'upgradeToAndCall', - outputs: [], - stateMutability: 'payable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// PriceOracleMock -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const priceOracleMockAbi = [ - { - type: 'constructor', - inputs: [ - { name: 'initialPrice', internalType: 'int256', type: 'int256' }, - { name: 'decimals_', internalType: 'uint8', type: 'uint8' }, - ], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'decimals', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'description', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'freezeTimestamp', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: '', internalType: 'uint80', type: 'uint80' }], - name: 'getRoundData', - outputs: [ - { name: 'roundId', internalType: 'uint80', type: 'uint80' }, - { name: 'answer', internalType: 'int256', type: 'int256' }, - { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'latestRoundData', - outputs: [ - { name: 'roundId', internalType: 'uint80', type: 'uint80' }, - { name: 'answer', internalType: 'int256', type: 'int256' }, - { name: 'startedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'updatedAt', internalType: 'uint256', type: 'uint256' }, - { name: 'answeredInRound', internalType: 'uint80', type: 'uint80' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'price', internalType: 'int256', type: 'int256' }, - { name: 'decimals_', internalType: 'uint8', type: 'uint8' }, - ], - name: 'setPrice', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'unfreezeTimestamp', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'version', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'pure', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// USDCMock -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const usdcMockAbi = [ - { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, - { type: 'error', inputs: [], name: 'ECDSAInvalidSignature' }, - { - type: 'error', - inputs: [{ name: 'length', internalType: 'uint256', type: 'uint256' }], - name: 'ECDSAInvalidSignatureLength', - }, - { - type: 'error', - inputs: [{ name: 's', internalType: 'bytes32', type: 'bytes32' }], - name: 'ECDSAInvalidSignatureS', - }, - { - type: 'error', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'allowance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientAllowance', - }, - { - type: 'error', - inputs: [ - { name: 'sender', internalType: 'address', type: 'address' }, - { name: 'balance', internalType: 'uint256', type: 'uint256' }, - { name: 'needed', internalType: 'uint256', type: 'uint256' }, - ], - name: 'ERC20InsufficientBalance', - }, - { - type: 'error', - inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidApprover', - }, - { - type: 'error', - inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidReceiver', - }, - { - type: 'error', - inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSender', - }, - { - type: 'error', - inputs: [{ name: 'spender', internalType: 'address', type: 'address' }], - name: 'ERC20InvalidSpender', - }, - { - type: 'error', - inputs: [{ name: 'deadline', internalType: 'uint256', type: 'uint256' }], - name: 'ERC2612ExpiredSignature', - }, - { - type: 'error', - inputs: [ - { name: 'signer', internalType: 'address', type: 'address' }, - { name: 'owner', internalType: 'address', type: 'address' }, - ], - name: 'ERC2612InvalidSigner', - }, - { - type: 'error', - inputs: [ - { name: 'account', internalType: 'address', type: 'address' }, - { name: 'currentNonce', internalType: 'uint256', type: 'uint256' }, - ], - name: 'InvalidAccountNonce', - }, - { type: 'error', inputs: [], name: 'InvalidShortString' }, - { - type: 'error', - inputs: [{ name: 'str', internalType: 'string', type: 'string' }], - name: 'StringTooLong', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'owner', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'spender', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Approval', - }, - { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, - { - type: 'event', - anonymous: false, - inputs: [ - { name: 'from', internalType: 'address', type: 'address', indexed: true }, - { name: 'to', internalType: 'address', type: 'address', indexed: true }, - { - name: 'value', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'Transfer', - }, - { - type: 'function', - inputs: [], - name: 'DOMAIN_SEPARATOR', - outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - ], - name: 'allowance', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'approve', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'account', internalType: 'address', type: 'address' }], - name: 'balanceOf', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'decimals', - outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], - stateMutability: 'pure', - }, - { - type: 'function', - inputs: [], - name: 'eip712Domain', - outputs: [ - { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, - { name: 'name', internalType: 'string', type: 'string' }, - { name: 'version', internalType: 'string', type: 'string' }, - { name: 'chainId', internalType: 'uint256', type: 'uint256' }, - { name: 'verifyingContract', internalType: 'address', type: 'address' }, - { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, - { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, - ], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'name', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], - name: 'nonces', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'owner', internalType: 'address', type: 'address' }, - { name: 'spender', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - { name: 'deadline', internalType: 'uint256', type: 'uint256' }, - { name: 'v', internalType: 'uint8', type: 'uint8' }, - { name: 'r', internalType: 'bytes32', type: 'bytes32' }, - { name: 's', internalType: 'bytes32', type: 'bytes32' }, - ], - name: 'permit', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [], - name: 'symbol', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'totalSupply', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [ - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transfer', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { name: 'from', internalType: 'address', type: 'address' }, - { name: 'to', internalType: 'address', type: 'address' }, - { name: 'value', internalType: 'uint256', type: 'uint256' }, - ], - name: 'transferFrom', - outputs: [{ name: '', internalType: 'bool', type: 'bool' }], - stateMutability: 'nonpayable', - }, -] as const - -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// Versionable -////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -export const versionableAbi = [ - { - type: 'function', - inputs: [], - name: 'VERSION', - outputs: [{ name: '', internalType: 'string', type: 'string' }], - stateMutability: 'view', - }, -] as const diff --git a/market-maker/src/adapters/perps/collateral.ts b/market-maker/src/adapters/perps/collateral.ts deleted file mode 100644 index b03ba41..0000000 --- a/market-maker/src/adapters/perps/collateral.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { Account, Chain, PublicClient, WalletClient } from "viem"; -import { erc20Abi } from "viem"; -import type pino from "pino"; -import { hashPowerPerpsDexAbi, ierc20PermitAbi, ierc5267Abi } from "./abi.ts"; - -const permitTypes = { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" }, - ], -} as const; - -/** - * Deposit `amount` of the perps' collateral token into the venue using ERC-2612 permit - * (no prior approve tx needed). Token must implement EIP-2612; if it also implements - * EIP-5267 we use that to discover the permit domain, otherwise we fall back to - * `name()` + `version()`. - */ -export async function topUpCollateralWithPermit(opts: { - publicClient: PublicClient; - walletClient: WalletClient; - account: Account; - chain: Chain; - perpsAddress: `0x${string}`; - collateralTokenAddress: `0x${string}`; - amount: bigint; - logger: pino.Logger; -}): Promise { - const { - publicClient, - walletClient, - account, - chain, - perpsAddress, - collateralTokenAddress, - amount, - logger, - } = opts; - if (amount <= 0n) return; - const owner = account.address; - logger.info({ amount: amount.toString() }, "topping up collateral"); - - const [nameResult, versionResult, nonceResult, eip712DomainResult] = await publicClient.multicall({ - allowFailure: true, - contracts: [ - { address: collateralTokenAddress, abi: erc20Abi, functionName: "name" }, - { - address: collateralTokenAddress, - abi: [ - { - inputs: [], - name: "version", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - ], - functionName: "version", - }, - { - address: collateralTokenAddress, - abi: ierc20PermitAbi, - functionName: "nonces", - args: [owner], - }, - { - address: collateralTokenAddress, - abi: ierc5267Abi, - functionName: "eip712Domain", - }, - ], - }); - - let domain: { name: string; version: string; chainId: number; verifyingContract: `0x${string}` }; - if (eip712DomainResult.status === "success") { - const [, name, version, chainId, verifyingContract] = eip712DomainResult.result; - domain = { name, version, chainId: Number(chainId), verifyingContract }; - } else { - if (nameResult.status === "failure") throw nameResult.error; - domain = { - name: nameResult.result, - version: versionResult.status === "success" ? versionResult.result || "1" : "1", - chainId: chain.id, - verifyingContract: collateralTokenAddress, - }; - } - - if (nonceResult.status === "failure") throw nonceResult.error; - const nonce = nonceResult.result; - const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); - - const signature = await walletClient.signTypedData({ - account, - domain, - types: permitTypes, - primaryType: "Permit", - message: { owner, spender: perpsAddress, value: amount, nonce, deadline }, - }); - - const r = `0x${signature.slice(2, 66)}` as `0x${string}`; - const s = `0x${signature.slice(66, 130)}` as `0x${string}`; - const v = Number.parseInt(signature.slice(130, 132), 16); - - const hash = await walletClient.writeContract({ - address: perpsAddress, - abi: hashPowerPerpsDexAbi, - functionName: "addCollateralWithPermit", - args: [amount, deadline, v, r, s], - account, - chain, - }); - await publicClient.waitForTransactionReceipt({ hash }); - logger.info({ amount: amount.toString() }, "collateral topped up"); -} diff --git a/market-maker/src/adapters/perps/events.ts b/market-maker/src/adapters/perps/events.ts new file mode 100644 index 0000000..4451e34 --- /dev/null +++ b/market-maker/src/adapters/perps/events.ts @@ -0,0 +1,89 @@ +import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; +import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; +import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; + +const PERPS_INSTRUMENT_ID = "perps"; + +type PerpsLog = Log; + +/** Multiplexes one viem watcher across many subscribers. Decode-only. */ +export class PerpsVenueEvents implements VenueEvents { + private listeners = new Set<(event: VenueEvent) => void>(); + private unwatch: WatchContractEventReturnType | null = null; + + constructor( + private readonly publicClient: PublicClient, + private readonly address: `0x${string}`, + ) {} + + subscribe(cb: (event: VenueEvent) => void): Unsubscribe { + this.listeners.add(cb); + if (this.unwatch === null) this.attachWatcher(); + return () => { + this.listeners.delete(cb); + if (this.listeners.size === 0) this.detachWatcher(); + }; + } + + private attachWatcher(): void { + this.unwatch = this.publicClient.watchContractEvent({ + address: this.address, + abi: HashPowerPerpsDEXAbi, + onLogs: (logs) => { + for (const log of logs) { + const evt = decodeEvent(log as PerpsLog); + if (evt) for (const l of this.listeners) l(evt); + } + }, + }); + } + + private detachWatcher(): void { + this.unwatch?.(); + this.unwatch = null; + } +} + +/** Map a perps contract event into a `VenueEvent`. Returns null for unhandled events. */ +export function decodeEvent(log: PerpsLog): VenueEvent | null { + switch (log.eventName) { + case "OrderCreated": { + const { orderId, participant, price, quantity } = log.args; + if (!orderId || !participant || price === undefined || quantity === undefined) return null; + return { + type: "order-created", + orderId, + participant, + price, + side: quantity > 0n ? "buy" : "sell", + size: quantity > 0n ? quantity : -quantity, + instrumentId: PERPS_INSTRUMENT_ID, + }; + } + case "OrderCancelled": { + const { orderId, participant } = log.args; + if (!orderId || !participant) return null; + return { type: "order-cancelled", orderId, participant, instrumentId: PERPS_INSTRUMENT_ID }; + } + case "OrderUpdated": { + const { orderId, participant, newQuantity } = log.args; + if (!orderId || !participant || newQuantity === undefined) return null; + return { + type: "order-updated", + orderId, + participant, + newSize: newQuantity > 0n ? newQuantity : -newQuantity, + instrumentId: PERPS_INSTRUMENT_ID, + }; + } + case "OrderMatched": { + const { makerOrderId, maker, taker } = log.args; + if (!makerOrderId) return null; + return { type: "order-matched", makerOrderId, maker, taker, instrumentId: PERPS_INSTRUMENT_ID }; + } + default: + return null; + } +} + +export const PERPS_INSTRUMENT_ID_CONST = PERPS_INSTRUMENT_ID; diff --git a/market-maker/src/adapters/perps/factory.ts b/market-maker/src/adapters/perps/factory.ts deleted file mode 100644 index 6b823a2..0000000 --- a/market-maker/src/adapters/perps/factory.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { encodeFunctionData, erc20Abi } from "viem"; -import type { Log, WatchContractEventReturnType } from "viem"; -import type pino from "pino"; -import { - type AdapterFactoryContext, - type CollateralSnapshot, - type DepthLevel, - type InstrumentAdapter, - type InstrumentContext, - type OrderBookSnapshot, - type OwnOrder, - type Position, - type Unsubscribe, - type VenueAdapter, - type VenueEvent, - type WalletContext, - registerAdapter, -} from "../../index.ts"; -import { hashPowerPerpsDexAbi, multicall3Abi } from "./abi.ts"; -import { topUpCollateralWithPermit } from "./collateral.ts"; - -const PERPS_INSTRUMENT_ID = "perps"; - -interface PerpsVenueOptions { - ctx: AdapterFactoryContext; - address: `0x${string}`; - wallet: WalletContext; - logger: pino.Logger; -} - -export class PerpsVenueAdapter implements VenueAdapter { - readonly kind = "perps" as const; - readonly wallet: WalletContext; - readonly publicClient: AdapterFactoryContext["network"]["publicClient"]; - readonly chain: AdapterFactoryContext["network"]["chain"]; - readonly transport: AdapterFactoryContext["network"]["transport"]; - readonly address: `0x${string}`; - - private readonly logger: pino.Logger; - private collateralTokenCache: `0x${string}` | null = null; - - constructor(opts: PerpsVenueOptions) { - this.wallet = opts.wallet; - this.publicClient = opts.ctx.network.publicClient; - this.chain = opts.ctx.network.chain; - this.transport = opts.ctx.network.transport; - this.address = opts.address; - this.logger = opts.logger.child({ component: "perps-venue" }); - } - - async listInstruments(): Promise { - return [new PerpsInstrumentAdapter(this)]; - } - - async getCollateralTokenAddress(): Promise<`0x${string}`> { - if (this.collateralTokenCache) return this.collateralTokenCache; - this.collateralTokenCache = await this.publicClient.readContract({ - address: this.address, - abi: hashPowerPerpsDexAbi, - functionName: "collateralToken", - }); - return this.collateralTokenCache; - } - - async getCollateral(): Promise { - const collateralTokenAddress = await this.getCollateralTokenAddress(); - const multicall3Address = this.chain.contracts?.multicall3?.address as `0x${string}` | undefined; - if (!multicall3Address) { - throw new Error(`chain ${this.chain.name} has no multicall3 address configured`); - } - const owner = this.wallet.account.address; - const results = await this.publicClient.multicall({ - allowFailure: false, - contracts: [ - { address: this.address, abi: hashPowerPerpsDexAbi, functionName: "balanceOf", args: [owner] }, - { address: collateralTokenAddress, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, - { address: this.address, abi: hashPowerPerpsDexAbi, functionName: "getMaintenanceMargin", args: [owner] }, - { address: multicall3Address, abi: multicall3Abi, functionName: "getEthBalance", args: [owner] }, - ], - }); - return { - balance: results[0], - walletTokenBalance: results[1], - maintenanceMargin: results[2], - nativeBalance: results[3], - collateralTokenAddress, - }; - } - - async topUpCollateral(amount: bigint): Promise { - if (amount <= 0n) return; - const collateralTokenAddress = await this.getCollateralTokenAddress(); - await topUpCollateralWithPermit({ - publicClient: this.publicClient, - walletClient: this.wallet.walletClient, - account: this.wallet.account, - chain: this.chain, - perpsAddress: this.address, - collateralTokenAddress, - amount, - logger: this.logger, - }); - } - - async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { - 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, - }); - } - - subscribeVenueEvents(handler: (event: VenueEvent) => void): Unsubscribe { - const ownAddress = this.wallet.account.address.toLowerCase(); - const unwatch: WatchContractEventReturnType = this.publicClient.watchContractEvent({ - address: this.address, - abi: hashPowerPerpsDexAbi, - onLogs: (logs) => { - for (const log of logs) { - const evt = decodeEvent(log as PerpsLog, ownAddress); - if (evt) handler(evt); - } - }, - }); - return () => unwatch(); - } -} - -class PerpsInstrumentAdapter implements InstrumentAdapter { - readonly id = PERPS_INSTRUMENT_ID; - readonly venue: PerpsVenueAdapter; - - constructor(venue: PerpsVenueAdapter) { - this.venue = venue; - } - - async getIndexPrice(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getMarketPrice", - }); - } - - async getMinTick(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "minimumPriceIncrement", - }); - } - - async getOwnOrders(): Promise { - const owner = this.venue.wallet.account.address; - const orderIds = await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getUserOrders", - args: [owner], - }); - if (orderIds.length === 0) return []; - const calls = orderIds.map((id) => ({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getOrder" as const, - args: [id] as const, - })); - const results = await this.venue.publicClient.multicall({ allowFailure: false, contracts: calls }); - return orderIds.map((orderId, i) => ({ - orderId, - price: results[i].price, - quantity: results[i].quantity, - instrumentId: this.id, - })); - } - - async getPosition(): Promise { - const owner = this.venue.wallet.account.address; - const pos = await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getUserPosition", - args: [owner], - }); - return { netQuantity: pos.netQuantity, entryPrice: pos.aggregatedEntryPrice }; - } - - async getContext(): Promise { - return {}; - } - - async getOrderBookSnapshot(opts: { depth?: number } = {}): Promise { - const depth = BigInt(opts.depth ?? 200); - const [bidPrices, askPrices] = await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getOrderBookPrices", - args: [depth], - }); - if (bidPrices.length === 0 && askPrices.length === 0) { - return { bids: [], asks: [] }; - } - const depthCalls = [ - ...bidPrices.map((p) => ({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getQuantityAtPrice" as const, - args: [p, true] as const, - })), - ...askPrices.map((p) => ({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "getQuantityAtPrice" as const, - args: [p, false] as const, - })), - ]; - const results = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: depthCalls, - }); - const bids: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); - const asks: DepthLevel[] = askPrices.map((p, i) => ({ - price: p, - quantity: results[bidPrices.length + i], - })); - return { bids, asks }; - } - - buildCancelCalldata(orderId: `0x${string}`): `0x${string}` { - return encodeFunctionData({ - abi: hashPowerPerpsDexAbi, - functionName: "cancelOrder", - args: [orderId], - }); - } - - buildCreateCalldata(price: bigint, quantity: bigint): `0x${string}` { - return encodeFunctionData({ - abi: hashPowerPerpsDexAbi, - functionName: "createOrder", - args: [price, quantity], - }); - } - - async estimateCreateGas(account: `0x${string}`): Promise { - try { - return await this.venue.publicClient.estimateContractGas({ - address: this.venue.address, - abi: hashPowerPerpsDexAbi, - functionName: "createOrder", - args: [1_000_000n, 1_000_000n], - account, - }); - } catch { - return 0n; - } - } -} - -type PerpsLog = Log; - -function decodeEvent(log: PerpsLog, ownAddressLower: string): VenueEvent | null { - switch (log.eventName) { - case "OrderCreated": { - const { orderId, participant, price, quantity } = log.args; - if (!orderId || !participant || price === undefined || quantity === undefined) return null; - const isOwn = participant.toLowerCase() === ownAddressLower; - return { - type: "order-created", - order: { orderId, price, quantity, instrumentId: PERPS_INSTRUMENT_ID }, - isOwn, - }; - } - case "OrderCancelled": { - const { orderId, participant } = log.args; - if (!orderId || !participant) return null; - return { - type: "order-cancelled", - orderId, - isOwn: participant.toLowerCase() === ownAddressLower, - instrumentId: PERPS_INSTRUMENT_ID, - }; - } - case "OrderUpdated": { - const { orderId, participant, newQuantity } = log.args; - if (!orderId || !participant || newQuantity === undefined) return null; - return { - type: "order-updated", - orderId, - newQuantity, - isOwn: participant.toLowerCase() === ownAddressLower, - instrumentId: PERPS_INSTRUMENT_ID, - }; - } - case "OrderMatched": { - const { makerOrderId, maker, taker } = log.args; - if (!makerOrderId) return null; - const isOwn = - (maker?.toLowerCase() === ownAddressLower) || - (taker?.toLowerCase() === ownAddressLower); - return { - type: "order-matched", - makerOrderId, - isOwn, - instrumentId: PERPS_INSTRUMENT_ID, - }; - } - default: - return null; - } -} - -/** Register the perps factory on import. Idempotent. */ -let registered = false; -export function registerPerpsAdapter(): void { - if (registered) return; - registered = true; - registerAdapter("perps", async (ctx) => { - const wallet = ctx.wallets.get(ctx.config.venue.wallet); - return new PerpsVenueAdapter({ - ctx, - address: ctx.config.venue.address, - wallet, - logger: ctx.logger, - }); - }); -} diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts index 8878b65..3dc5962 100644 --- a/market-maker/src/adapters/perps/index.ts +++ b/market-maker/src/adapters/perps/index.ts @@ -1,6 +1,28 @@ -export * from "./factory.ts"; -export * from "./abi.ts"; -export { topUpCollateralWithPermit } from "./collateral.ts"; +import type pino from "pino"; +import type { NetworkClients } from "../../core/client.ts"; +import type { VenueAdapter, WalletContext } from "../../core/adapter.ts"; +import { PerpsVenueAdapter } from "./venue.ts"; -import { registerPerpsAdapter } from "./factory.ts"; -registerPerpsAdapter(); +export interface CreatePerpsVenueOpts { + network: NetworkClients; + wallet: WalletContext; + address: `0x${string}`; + /** Optional Multicall3 override; defaults to chain.contracts.multicall3.address. */ + multicall3Address?: `0x${string}`; + logger: pino.Logger; +} + +/** + * Construct a perps venue adapter. Static wiring — no registry lookup. + * + * Caller is responsible for providing the wallet and Multicall3 address; + * `WalletRegistry` and `createNetworkClients` from core handle both. + * + * matchingMode = "limit" — orders fill at any price better-or-equal. + */ +export async function createPerpsVenue(opts: CreatePerpsVenueOpts): Promise { + return new PerpsVenueAdapter(opts); +} + +export { PerpsVenueAdapter } from "./venue.ts"; +export { PerpsInstrumentAdapter } from "./instrument.ts"; diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts new file mode 100644 index 0000000..40c83f2 --- /dev/null +++ b/market-maker/src/adapters/perps/instrument.ts @@ -0,0 +1,253 @@ +import { encodeFunctionData } from "viem"; +import type { + BookSource, + CancelIntent, + DepthLevel, + InstrumentAdapter, + InstrumentContext, + MatchingMode, + OrderBookSnapshot, + OrderIntent, + OwnOrder, + OwnOrderEvent, + OwnOrderSource, + Position, + Unsubscribe, +} from "../../core/adapter.ts"; +import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; +import { calculateNotional } from "../../core/math.ts"; +import type { PerpsVenueAdapter } from "./venue.ts"; + +const PERPS_INSTRUMENT_ID = "perps"; + +export class PerpsInstrumentAdapter implements InstrumentAdapter { + readonly id = PERPS_INSTRUMENT_ID; + readonly venue: PerpsVenueAdapter; + readonly book: BookSource; + readonly ownOrders: OwnOrderSource; + + private tickCache: bigint | null = null; + + constructor(venue: PerpsVenueAdapter) { + this.venue = venue; + this.book = new PerpsBook(this); + this.ownOrders = new PerpsOwnOrders(this); + } + + async getIndexPrice(): Promise { + return await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getMarketPrice", + }); + } + + async getPosition(): Promise { + const owner = this.venue.wallet.account.address; + const pos = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserPosition", + args: [owner], + }); + return { netQuantity: pos.netQuantity, entryPrice: pos.aggregatedEntryPrice }; + } + + async getContext(): Promise { + // Eagerly cache the IM spot shock so `estimateOrderMargin` is synchronous. + await this.venue.fetchImSpotShock(); + return {}; + } + + encodeCreate(intent: OrderIntent): `0x${string}` { + // Perps' createOrder takes a SIGNED quantity (positive = buy, negative = sell). + const signed = intent.side === "buy" ? intent.size : -intent.size; + return encodeFunctionData({ + abi: HashPowerPerpsDEXAbi, + functionName: "createOrder", + args: [intent.price, signed], + }); + } + + encodeCancel(intent: CancelIntent): `0x${string}` { + return encodeFunctionData({ + abi: HashPowerPerpsDEXAbi, + functionName: "cancelOrder", + args: [intent.orderId], + }); + } + + /** + * Mirrors `HashPowerPerpsDEX._getMargin` for a single new resting order: + * IM_added = imSpotShock × notional / 1e18 + * + * The on-chain formula reduces this by any "risk-reducing" overlap with + * an existing position, but the MM is conservative on the high side here: + * we estimate ignoring the reducer (worst-case more IM, never less), + * so the engine.canPlaceOrder gate has slack rather than slop. + * + * Returns 0n if `imSpotShock` hasn't been cached yet — caller treats + * "0 additional" as "no information; proceed", which is fine on first + * tick because the engine itself enforces the floor. + */ + estimateOrderMargin(intent: OrderIntent): bigint { + // Ensure the venue has a cached spot shock; if not, fall back to the + // no-op estimate. The first canPlace call is allowed through optimistically. + // The cached value is fetched lazily by `account.imSpotShock()` and cached. + const cached = (this.venue as unknown as { imSpotShockCache?: bigint }).imSpotShockCache; + if (!cached) return 0n; + const notional = calculateNotional(intent.price, intent.size); + return (notional * cached) / 10n ** 18n; + } + + async estimateCreateGas(account: `0x${string}`): Promise { + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "createOrder", + args: [1_000_000n, 1_000_000n], + account, + }); + } catch { + return 0n; + } + } + + async getMinTick(): Promise { + if (this.tickCache !== null) return this.tickCache; + const tick = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "minimumPriceIncrement", + }); + this.tickCache = tick; + return tick; + } +} + +class PerpsBook implements BookSource { + readonly matchingMode: MatchingMode = "limit"; + constructor(private readonly inst: PerpsInstrumentAdapter) {} + + tick(): Promise { + return this.inst.getMinTick(); + } + + async snapshot(opts: { depth?: number } = {}): Promise { + const v = this.inst.venue; + const depth = BigInt(opts.depth ?? 200); + const [bidPrices, askPrices] = await v.publicClient.readContract({ + address: v.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getOrderBookPrices", + args: [depth], + }); + if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; + + const depthCalls = [ + ...bidPrices.map((p) => ({ + address: v.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, true] as const, + })), + ...askPrices.map((p) => ({ + address: v.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getQuantityAtPrice" as const, + args: [p, false] as const, + })), + ]; + const results = await v.publicClient.multicall({ allowFailure: false, contracts: depthCalls }); + const bids: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); + const asks: DepthLevel[] = askPrices.map((p, i) => ({ + price: p, + quantity: results[bidPrices.length + i], + })); + return { bids, asks }; + } +} + +/** + * Stateless on-chain own-order source: every `list()` call hits the chain. + * Subscribe filters venue events to the wallet and forwards them as + * `OwnOrderEvent`s; no internal cache is required because `list()` is the + * source of truth. + */ +class PerpsOwnOrders implements OwnOrderSource { + constructor(private readonly inst: PerpsInstrumentAdapter) {} + + async list(): Promise { + const v = this.inst.venue; + const owner = v.wallet.account.address; + const orderIds = await v.publicClient.readContract({ + address: v.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserOrders", + args: [owner], + }); + if (orderIds.length === 0) return []; + const calls = orderIds.map((id) => ({ + address: v.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + const results = await v.publicClient.multicall({ allowFailure: false, contracts: calls }); + return orderIds.map((orderId, i) => { + const q = results[i].quantity; + return { + orderId, + price: results[i].price, + side: q > 0n ? "buy" : "sell", + size: q > 0n ? q : -q, + instrumentId: PERPS_INSTRUMENT_ID, + } satisfies OwnOrder; + }); + } + + subscribe(cb: (event: OwnOrderEvent) => void): Unsubscribe { + const own = this.inst.venue.wallet.account.address.toLowerCase(); + return this.inst.venue.events.subscribe((evt) => { + switch (evt.type) { + case "order-created": { + if (evt.participant.toLowerCase() !== own) return; + cb({ + type: "added", + orderId: evt.orderId, + order: { + orderId: evt.orderId, + price: evt.price, + side: evt.side, + size: evt.size, + instrumentId: PERPS_INSTRUMENT_ID, + }, + }); + return; + } + case "order-cancelled": { + if (evt.participant.toLowerCase() !== own) return; + cb({ type: "removed", orderId: evt.orderId }); + return; + } + case "order-updated": { + if (evt.participant.toLowerCase() !== own) return; + // We don't have side/price from the update event; signal a refresh + // is needed by emitting "removed". The next BookTracker resync will + // re-pick it up via list() if it still exists. + if (evt.newSize === 0n) { + cb({ type: "removed", orderId: evt.orderId }); + } else { + cb({ type: "updated", orderId: evt.orderId }); + } + return; + } + } + }); + } + + async bootstrap(_opts?: { fromBlock?: bigint }): Promise { + // No-op: list() is the source of truth and reads from chain on demand. + } +} diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts new file mode 100644 index 0000000..6d031cb --- /dev/null +++ b/market-maker/src/adapters/perps/venue.ts @@ -0,0 +1,221 @@ +import { erc20Abi } from "viem"; +import type { Chain, PublicClient, Transport } from "viem"; +import type pino from "pino"; +import type { + CollateralAccount, + CollateralSnapshot, + InstrumentAdapter, + VenueAdapter, + VenueEvents, + WalletContext, +} from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.ts"; +import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; +import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; +import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; +import { Multicall3Abi } from "../../abi/Multicall3.ts"; +import { depositToVault } from "../../core/vaultDeposit.ts"; +import { PerpsInstrumentAdapter } from "./instrument.ts"; +import { PerpsVenueEvents } from "./events.ts"; + +export interface PerpsVenueOptions { + network: NetworkClients; + wallet: WalletContext; + address: `0x${string}`; + multicall3Address?: `0x${string}`; + logger: pino.Logger; +} + +/** + * Perps venue: HashPowerPerpsDEX, the shared CollateralVault, and the + * PortfolioMarginEngine. + * + * Wiring is fixed at construction. The adapter discovers `vault` and + * `portfolioMargin` from the DEX on the first call to `account.snapshot()` + * and caches them. + * + * Single-instrument: `getInstrument()` returns the perps order book. + */ +export class PerpsVenueAdapter implements VenueAdapter { + readonly kind = "perps" as const; + readonly wallet: WalletContext; + readonly publicClient: PublicClient; + readonly chain: Chain; + readonly transport: Transport; + readonly address: `0x${string}`; + + readonly events: VenueEvents; + readonly account: CollateralAccount; + + private readonly logger: pino.Logger; + private readonly multicall3Address: `0x${string}`; + private instrumentSingleton: PerpsInstrumentAdapter | null = null; + + /** Cached references discovered from the DEX. */ + private vaultAddressCache: `0x${string}` | null = null; + private engineAddressCache: `0x${string}` | null = null; + private collateralTokenCache: `0x${string}` | null = null; + private imSpotShockCache: bigint | null = null; + + constructor(opts: PerpsVenueOptions) { + this.wallet = opts.wallet; + this.publicClient = opts.network.publicClient; + this.chain = opts.network.chain; + this.transport = opts.network.transport; + this.address = opts.address; + this.logger = opts.logger.child({ component: "perps-venue" }); + + const mc3 = opts.multicall3Address ?? (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); + if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); + this.multicall3Address = mc3; + + this.events = new PerpsVenueEvents(this.publicClient, this.address); + this.account = new PerpsCollateralAccount(this); + } + + async getInstrument(): Promise { + if (!this.instrumentSingleton) { + this.instrumentSingleton = new PerpsInstrumentAdapter(this); + } + return this.instrumentSingleton; + } + + async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { + 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, + }); + } + + // ── Internal helpers used by the collateral account & instrument ───────── + + async resolveAddresses(): Promise<{ vault: `0x${string}`; engine: `0x${string}`; token: `0x${string}` }> { + if (this.vaultAddressCache && this.engineAddressCache && this.collateralTokenCache) { + return { + vault: this.vaultAddressCache, + engine: this.engineAddressCache, + token: this.collateralTokenCache, + }; + } + const [vault, engine, token] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "vault" }, + { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "portfolioMargin" }, + { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "collateralToken" }, + ], + }); + this.vaultAddressCache = vault; + this.engineAddressCache = engine; + this.collateralTokenCache = token; + return { vault, engine, token }; + } + + async getMulticall3Address(): Promise<`0x${string}`> { + return this.multicall3Address; + } + + async fetchImSpotShock(): Promise { + if (this.imSpotShockCache !== null) return this.imSpotShockCache; + const { engine } = await this.resolveAddresses(); + const shock = await this.publicClient.readContract({ + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "imSpotShock", + }); + this.imSpotShockCache = shock; + return shock; + } +} + +/** + * `CollateralAccount` for the perps venue. + * + * `snapshot()` pulls 8 reads in one multicall: vault balance, portfolio + * IM/MM, perps order margin / unrealized PnL / pending funding (signed), + * wallet ERC20 balance, native ETH balance. + * + * `deposit(amount)` is delegated to the shared `vaultDeposit` helper; the + * old `addCollateralWithPermit` path no longer exists on the contract. + */ +class PerpsCollateralAccount implements CollateralAccount { + constructor(private readonly venue: PerpsVenueAdapter) {} + + async snapshot(): Promise { + const owner = this.venue.wallet.account.address; + const { vault, engine, token } = await this.venue.resolveAddresses(); + const mc3 = await this.venue.getMulticall3Address(); + + const [ + vaultBalance, + portfolioIM, + portfolioMM, + orderMargin, + perpsUnrealizedPnl, + pendingFunding, + walletTokenBalance, + nativeBalance, + ] = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getOrderMargin", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ], + }); + + // Funding owed (positive) reduces effective unrealized PnL. + const venueUnrealizedPnl = perpsUnrealizedPnl - pendingFunding; + + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: orderMargin, + venueUnrealizedPnl, + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; + } + + imSpotShock(): Promise { + return this.venue.fetchImSpotShock(); + } + + async deposit(amount: bigint): Promise { + if (amount <= 0n) return; + const { vault, token } = await this.venue.resolveAddresses(); + await depositToVault({ + publicClient: this.venue.publicClient, + walletClient: this.venue.wallet.walletClient, + account: this.venue.wallet.account, + chain: this.venue.chain, + vaultAddress: vault, + collateralToken: token, + amount, + logger: this.venue["logger"], + }); + } + + async canPlace(additionalIM: bigint): Promise { + if (additionalIM === 0n) return true; + const { engine } = await this.venue.resolveAddresses(); + return await this.venue.publicClient.readContract({ + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "canPlaceOrder", + args: [this.venue.wallet.account.address, additionalIM], + }); + } +} diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts new file mode 100644 index 0000000..66dc98c --- /dev/null +++ b/market-maker/src/apps/futures/config.ts @@ -0,0 +1,96 @@ +import { type Static, Type } from "@sinclair/typebox"; +import { + TypeEthAddress, + collateralSchema, + gasSchema, + healthSchema, + loadConfigFromFile, + networkSchema, + riskSchema, + timingSchema, + walletSchema, +} from "../../core/config/base.ts"; +import { ConfigError } from "../../core/errors.ts"; + +/** + * Futures app config schema. + * + * Pricing locked to "reservation-price" (Avellaneda–Stoikov) — that's the + * strategy that fits exact-match futures: the inventory shift on r is more + * useful than a symmetric spread because price levels are non-fungible + * (each is a separate fill opportunity). + * + * Sizing locked to "geometric-taper" so the front level (highest fill prob) + * is the largest. taperRatio in (0, 1) is required. + */ +const Closed = { additionalProperties: false }; + +const futuresVenueSchema = Type.Object( + { + kind: Type.Literal("futures"), + address: TypeEthAddress(), + wallet: Type.String(), + }, + Closed, +); + +const futuresPricingSchema = Type.Object( + { + strategy: Type.Literal("reservation-price"), + /** Avellaneda–Stoikov risk aversion γ. */ + riskAversion: Type.Number({ minimum: 0 }), + /** Fallback remaining time (seconds) when InstrumentContext.deliveryDate is unavailable. */ + marginCallTimeSeconds: Type.Number({ minimum: 0 }), + minSpreadBps: Type.Number({ minimum: 0 }), + volatilityMultiplier: Type.Number({ minimum: 0 }), + /** maxSkewTicks is unused under reservation-price (skew is in the formula); pinned at 0. */ + maxSkewTicks: Type.Number({ const: 0, default: 0 }), + }, + Closed, +); + +const futuresSizingSchema = Type.Object( + { + strategy: Type.Literal("geometric-taper"), + /** Total per-side budget in token base units. */ + baseQuantity: Type.String(), + numLevelsPerSide: Type.Number({ minimum: 1 }), + taperRatio: Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1 }), + }, + Closed, +); + +export const futuresRootSchema = Type.Object( + { + nodeEnv: Type.String({ default: "development" }), + commitHash: Type.String({ default: "unknown" }), + logLevel: Type.String({ default: "info" }), + dryRun: Type.Boolean({ default: false }), + wallets: Type.Record(Type.String(), walletSchema), + network: networkSchema, + venue: futuresVenueSchema, + pricing: futuresPricingSchema, + sizing: futuresSizingSchema, + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + timing: timingSchema, + health: healthSchema, + }, + Closed, +); + +export type FuturesMakerConfig = Static; + +export function loadFuturesConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): FuturesMakerConfig { + return loadConfigFromFile({ + schema: futuresRootSchema, + path: opts.path, + env: opts.env, + validate: (cfg) => { + if (!cfg.wallets[cfg.venue.wallet]) { + throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); + } + }, + }); +} diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts new file mode 100644 index 0000000..78ebf12 --- /dev/null +++ b/market-maker/src/apps/futures/main.ts @@ -0,0 +1,174 @@ +import pino from "pino"; +import { loadDotenvFiles } from "../../core/env.ts"; +import { configBigint } from "../../core/config/base.ts"; +import { createNetworkClients } from "../../core/client.ts"; +import { WalletRegistry } from "../../core/wallet.ts"; +import { OracleTracker } from "../../core/oracleTracker.ts"; +import { GasTracker } from "../../core/gasTracker.ts"; +import { InventoryManager } from "../../core/inventoryManager.ts"; +import { CollateralTracker } from "../../core/collateralTracker.ts"; +import { RiskManager } from "../../core/riskManager.ts"; +import { BookTracker } from "../../core/bookTracker.ts"; +import { Quoter } from "../../core/quoter.ts"; +import { OrderExecutor } from "../../core/orderExecutor.ts"; +import { HealthCheck } from "../../core/healthcheck.ts"; +import { runMakerLoop } from "../../core/runner.ts"; +import { serializeError } from "../../core/errSerializer.ts"; +import { createFuturesVenue } from "../../adapters/futures/index.ts"; +import { loadFuturesConfig } from "./config.ts"; + +async function main(): Promise { + loadDotenvFiles(import.meta.dirname); + const config = loadFuturesConfig(); + const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); + logger.info({ venue: "futures", address: config.venue.address, dryRun: config.dryRun }, "starting futures mm"); + + const network = createNetworkClients(config.network.name, config.network.rpcUrl); + const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const wallet = wallets.get(config.venue.wallet); + + const venue = await createFuturesVenue({ + network, + wallet, + address: config.venue.address, + logger, + }); + const instrument = await venue.getInstrument(); + + // Futures' OwnOrderSource is cache-backed (no on-chain "list my orders"), + // so we explicitly seed the cache before the book tracker resyncs. Perps + // is stateless and skips this step. + await instrument.ownOrders.bootstrap(); + + const oracle = new OracleTracker(instrument, logger); + const gas = new GasTracker( + network.publicClient, + { + ethPriceFeedAddress: config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, + logger, + ); + const collateral = new CollateralTracker( + venue.account, + { + autoDeposit: config.collateral.autoDeposit, + autoDepositMinAmount: configBigint(config.collateral.autoDepositMinAmount, "collateral.autoDepositMinAmount"), + }, + logger, + ); + const risk = new RiskManager( + { + maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), + maxUtilizationPct: config.risk.maxUtilizationPct, + minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), + maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), + maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), + maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), + }, + inventory, + collateral, + gas, + oracle, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + + const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); + const quoter = new Quoter( + instrument, + { + pricing: { + strategy: "reservation-price", + riskAversion: config.pricing.riskAversion, + marginCallTimeSeconds: config.pricing.marginCallTimeSeconds, + minSpreadBps: config.pricing.minSpreadBps, + volatilityMultiplier: config.pricing.volatilityMultiplier, + gasPenaltyBps: config.risk.gasPenaltyBps, + }, + sizing: { + strategy: "geometric-taper", + baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + taperRatio: config.sizing.taperRatio, + }, + maxSkewTicks: config.pricing.maxSkewTicks, + levelSpacingTicks: config.timing.levelSpacingTicks, + }, + oracle, + gas, + inventory, + risk, + logger, + ); + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + requoteThresholdTicks: config.timing.requoteThresholdTicks, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + dryRun: config.dryRun, + }, + quoter, + book, + gas, + risk, + oracle, + logger, + ); + const health = new HealthCheck({ + port: config.health.port, + appName: "futures-mm", + configSummary: summariseConfig(config), + oracle, + inventory, + collateral, + book, + gas, + risk, + logger, + }); + + await runMakerLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + instrument, + oracle, + gas, + book, + inventory, + collateral, + risk, + quoter, + executor, + health, + logger, + }); +} + +function summariseConfig(c: ReturnType): Record { + return { + nodeEnv: c.nodeEnv, + commitHash: c.commitHash, + logLevel: c.logLevel, + dryRun: c.dryRun, + network: c.network.name, + venue: { kind: c.venue.kind, address: c.venue.address }, + pricing: c.pricing, + sizing: c.sizing, + risk: c.risk, + gas: c.gas, + timing: c.timing, + }; +} + +main(); diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts new file mode 100644 index 0000000..3bd2b26 --- /dev/null +++ b/market-maker/src/apps/perps/config.ts @@ -0,0 +1,90 @@ +import { type Static, Type } from "@sinclair/typebox"; +import { + TypeEthAddress, + collateralSchema, + gasSchema, + healthSchema, + loadConfigFromFile, + networkSchema, + riskSchema, + timingSchema, + walletSchema, +} from "../../core/config/base.ts"; +import { ConfigError } from "../../core/errors.ts"; + +/** + * Perps app config schema. + * + * Pricing locked to "effective-spread" (symmetric, limit-matched) — that's + * the strategy that fits the perps order book. Sizing locked to "linear" + * for the same reason: deeper levels are larger because they only fill + * after the shallower ones do. + * + * No runtime ternaries — the schema demands the right shape, the loader + * rejects mismatches, and the Quoter / Executor read the static values. + */ +const Closed = { additionalProperties: false }; + +const perpsVenueSchema = Type.Object( + { + kind: Type.Literal("perps"), + address: TypeEthAddress(), + wallet: Type.String(), + }, + Closed, +); + +const perpsPricingSchema = Type.Object( + { + strategy: Type.Literal("effective-spread"), + minSpreadBps: Type.Number({ minimum: 0 }), + volatilityMultiplier: Type.Number({ minimum: 0 }), + inventorySkewGamma: Type.Number({ minimum: 0 }), + maxSkewTicks: Type.Number({ minimum: 0 }), + }, + Closed, +); + +const perpsSizingSchema = Type.Object( + { + strategy: Type.Literal("linear"), + baseQuantity: Type.String(), + numLevelsPerSide: Type.Number({ minimum: 1 }), + }, + Closed, +); + +export const perpsRootSchema = Type.Object( + { + nodeEnv: Type.String({ default: "development" }), + commitHash: Type.String({ default: "unknown" }), + logLevel: Type.String({ default: "info" }), + dryRun: Type.Boolean({ default: false }), + wallets: Type.Record(Type.String(), walletSchema), + network: networkSchema, + venue: perpsVenueSchema, + pricing: perpsPricingSchema, + sizing: perpsSizingSchema, + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + timing: timingSchema, + health: healthSchema, + }, + Closed, +); + +export type PerpsMakerConfig = Static; + +export function loadPerpsConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): PerpsMakerConfig { + return loadConfigFromFile({ + schema: perpsRootSchema, + path: opts.path, + env: opts.env, + validate: (cfg) => { + if (!cfg.wallets[cfg.venue.wallet]) { + throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); + } + }, + }); +} diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts new file mode 100644 index 0000000..d5659af --- /dev/null +++ b/market-maker/src/apps/perps/main.ts @@ -0,0 +1,167 @@ +import pino from "pino"; +import { loadDotenvFiles } from "../../core/env.ts"; +import { configBigint } from "../../core/config/base.ts"; +import { createNetworkClients } from "../../core/client.ts"; +import { WalletRegistry } from "../../core/wallet.ts"; +import { OracleTracker } from "../../core/oracleTracker.ts"; +import { GasTracker } from "../../core/gasTracker.ts"; +import { InventoryManager } from "../../core/inventoryManager.ts"; +import { CollateralTracker } from "../../core/collateralTracker.ts"; +import { RiskManager } from "../../core/riskManager.ts"; +import { BookTracker } from "../../core/bookTracker.ts"; +import { Quoter } from "../../core/quoter.ts"; +import { OrderExecutor } from "../../core/orderExecutor.ts"; +import { HealthCheck } from "../../core/healthcheck.ts"; +import { runMakerLoop } from "../../core/runner.ts"; +import { serializeError } from "../../core/errSerializer.ts"; +import { createPerpsVenue } from "../../adapters/perps/index.ts"; +import { loadPerpsConfig } from "./config.ts"; + +async function main(): Promise { + loadDotenvFiles(import.meta.dirname); + const config = loadPerpsConfig(); + const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); + logger.info({ venue: "perps", address: config.venue.address, dryRun: config.dryRun }, "starting perps mm"); + + const network = createNetworkClients(config.network.name, config.network.rpcUrl); + const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const wallet = wallets.get(config.venue.wallet); + + const venue = await createPerpsVenue({ + network, + wallet, + address: config.venue.address, + logger, + }); + const instrument = await venue.getInstrument(); + + const oracle = new OracleTracker(instrument, logger); + const gas = new GasTracker( + network.publicClient, + { + ethPriceFeedAddress: config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, + logger, + ); + const collateral = new CollateralTracker( + venue.account, + { + autoDeposit: config.collateral.autoDeposit, + autoDepositMinAmount: configBigint(config.collateral.autoDepositMinAmount, "collateral.autoDepositMinAmount"), + }, + logger, + ); + const risk = new RiskManager( + { + maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), + maxUtilizationPct: config.risk.maxUtilizationPct, + minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), + maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), + maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), + maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), + }, + inventory, + collateral, + gas, + oracle, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + + const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); + const quoter = new Quoter( + instrument, + { + pricing: { + strategy: "effective-spread", + minSpreadBps: config.pricing.minSpreadBps, + volatilityMultiplier: config.pricing.volatilityMultiplier, + inventorySkewGamma: config.pricing.inventorySkewGamma, + gasPenaltyBps: config.risk.gasPenaltyBps, + }, + sizing: { + strategy: "linear", + baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + }, + maxSkewTicks: config.pricing.maxSkewTicks, + levelSpacingTicks: config.timing.levelSpacingTicks, + }, + oracle, + gas, + inventory, + risk, + logger, + ); + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + requoteThresholdTicks: config.timing.requoteThresholdTicks, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + dryRun: config.dryRun, + }, + quoter, + book, + gas, + risk, + oracle, + logger, + ); + const health = new HealthCheck({ + port: config.health.port, + appName: "perps-mm", + configSummary: summariseConfig(config), + oracle, + inventory, + collateral, + book, + gas, + risk, + logger, + }); + + await runMakerLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + instrument, + oracle, + gas, + book, + inventory, + collateral, + risk, + quoter, + executor, + health, + logger, + }); +} + +function summariseConfig(c: ReturnType): Record { + return { + nodeEnv: c.nodeEnv, + commitHash: c.commitHash, + logLevel: c.logLevel, + dryRun: c.dryRun, + network: c.network.name, + venue: { kind: c.venue.kind, address: c.venue.address }, + pricing: c.pricing, + sizing: c.sizing, + risk: c.risk, + gas: c.gas, + timing: c.timing, + }; +} + +main(); diff --git a/market-maker/src/config.ts b/market-maker/src/config.ts deleted file mode 100644 index b53527d..0000000 --- a/market-maker/src/config.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import yaml from "js-yaml"; -import { type Static, type StringOptions, type TUnsafe, Type } from "@sinclair/typebox"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; -import { ConfigError } from "./errors.ts"; -import type { VenueKind } from "./adapter.ts"; - -const TypeEthAddress = (opt?: StringOptions) => - Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]{40}$" }) as TUnsafe<`0x${string}`>; - -const TypeHex = (opt?: StringOptions) => - Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]+$" }) as TUnsafe<`0x${string}`>; - -/** - * ${VAR} expansion. Recursively walks strings in the parsed YAML and replaces - * ${NAME} with process.env.NAME. Throws if the variable is not set unless a - * default is provided via the `${NAME:-default}` syntax. - */ -function expandEnv(value: unknown, env: NodeJS.ProcessEnv): unknown { - if (typeof value === "string") { - return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_match, name, def) => { - const v = env[name]; - if (v !== undefined && v !== "") return v; - if (def !== undefined) return def; - throw new ConfigError(`Environment variable "${name}" is not set`); - }); - } - if (Array.isArray(value)) { - return value.map((v) => expandEnv(v, env)); - } - if (value && typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = expandEnv(v, env); - } - return out; - } - return value; -} - -const walletSchema = Type.Object({ - privateKey: TypeHex(), -}); - -const networkSchema = Type.Object({ - name: Type.String(), - rpcUrl: Type.String(), - ethPriceFeed: Type.Optional(TypeEthAddress()), -}); - -const venueBase = Type.Object({ - kind: Type.Union([ - Type.Literal("perps"), - Type.Literal("futures"), - Type.Literal("options"), - ]), - address: TypeEthAddress(), - wallet: Type.String(), - eventsFromBlock: Type.Optional(Type.Number({ minimum: 0 })), -}); - -const pricingSchema = Type.Object({ - strategy: Type.Union([ - Type.Literal("effective-spread"), - Type.Literal("reservation-price"), - ]), - minSpreadBps: Type.Number({ minimum: 0 }), - volatilityMultiplier: Type.Number({ minimum: 0 }), - inventorySkewGamma: Type.Optional(Type.Number({ minimum: 0 })), - maxSkewTicks: Type.Number({ minimum: 0 }), - // reservation-price extras (optional; required at strategy load) - riskAversion: Type.Optional(Type.Number({ minimum: 0 })), - marginCallTimeSeconds: Type.Optional(Type.Number({ minimum: 0 })), -}); - -const sizingSchema = Type.Object({ - strategy: Type.Union([ - Type.Literal("linear"), - Type.Literal("geometric-taper"), - ]), - baseQuantity: Type.String(), // bigint-as-string - numLevelsPerSide: Type.Number({ minimum: 1 }), - // geometric-taper extras - taperRatio: Type.Optional(Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1 })), -}); - -const riskSchema = Type.Object({ - maxPositionSize: Type.String(), - maxUtilizationPct: Type.Number({ minimum: 0, maximum: 100, default: 80 }), - minCollateralBalance: Type.String(), - maxDailyLossUsd: Type.String(), - maxGasBudgetPerHourUsd: Type.String({ default: "50000000" }), - maxGasBudgetPerDayUsd: Type.String({ default: "500000000" }), - gasSpikeThresholdPct: Type.Number({ default: 200 }), - gasPenaltyBps: Type.Number({ default: 5 }), - urgentRequoteThresholdTicks: Type.Number({ default: 10 }), -}); - -const gasSchema = Type.Object({ - gasCapMultiplier: Type.Number({ default: 2.0 }), -}); - -const timingSchema = Type.Object({ - pollIntervalMs: Type.Number({ minimum: 100, default: 3000 }), - requoteThresholdTicks: Type.Number({ minimum: 0, default: 2 }), - requoteCooldownMs: Type.Number({ minimum: 0, default: 1000 }), - resyncIntervalMs: Type.Number({ minimum: 1000, default: 60000 }), -}); - -const healthSchema = Type.Object({ - port: Type.Number({ minimum: 0, default: 3001 }), -}); - -const rootSchema = Type.Object({ - nodeEnv: Type.String({ default: "development" }), - commitHash: Type.String({ default: "unknown" }), - logLevel: Type.String({ default: "info" }), - dryRun: Type.Boolean({ default: false }), - wallets: Type.Record(Type.String(), walletSchema), - network: networkSchema, - venue: venueBase, - pricing: pricingSchema, - sizing: sizingSchema, - risk: riskSchema, - gas: gasSchema, - timing: timingSchema, - health: healthSchema, -}); - -export type MakerConfig = Static; - -/** Parse a YAML file, expand ${VAR} tokens, validate against schema. */ -export function loadConfig(opts: { - path?: string; - env?: NodeJS.ProcessEnv; -} = {}): MakerConfig { - const env = opts.env ?? process.env; - const configPath = opts.path ?? env.MAKER_CONFIG ?? parseConfigArg(process.argv); - if (!configPath) { - throw new ConfigError( - "No config path provided. Use --config or set MAKER_CONFIG env var.", - ); - } - - const abs = resolve(process.cwd(), configPath); - let raw: string; - try { - raw = readFileSync(abs, "utf8"); - } catch (err) { - throw new ConfigError(`Failed to read config at ${abs}: ${(err as Error).message}`); - } - - const parsed = yaml.load(raw); - const expanded = expandEnv(parsed, env); - - const ajv = new Ajv.default({ - allErrors: true, - useDefaults: true, - coerceTypes: false, - }); - addFormats.default(ajv); - const validate = ajv.compile(rootSchema); - if (!validate(expanded)) { - const msgs = (validate.errors ?? []) - .map((e) => `${e.instancePath || ""} ${e.message ?? ""}`) - .join("; "); - throw new ConfigError(`Config validation failed: ${msgs}`); - } - - const cfg = expanded as MakerConfig; - - if (!cfg.wallets[cfg.venue.wallet]) { - throw new ConfigError( - `venue.wallet "${cfg.venue.wallet}" is not declared in wallets map`, - ); - } - - return cfg; -} - -function parseConfigArg(argv: readonly string[]): string | undefined { - for (let i = 0; i < argv.length; i++) { - if (argv[i] === "--config" && argv[i + 1]) return argv[i + 1]; - if (argv[i].startsWith("--config=")) return argv[i].slice("--config=".length); - } - return undefined; -} - -/** Parse a bigint-as-string value, throwing ConfigError on failure. */ -export function configBigint(value: string, field: string): bigint { - try { - return BigInt(value); - } catch { - throw new ConfigError(`Invalid bigint value for ${field}: "${value}"`); - } -} - -export type { VenueKind }; diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts new file mode 100644 index 0000000..98e047f --- /dev/null +++ b/market-maker/src/core/adapter.ts @@ -0,0 +1,270 @@ +import type { Account, Chain, PublicClient, Transport, WalletClient } from "viem"; + +// ─── Order intents ─────────────────────────────────────────────────────────── + +/** + * Side of a quote/order. The signed-bigint convention is intentionally NOT used + * at this boundary — quoter/executor pass `side` explicitly, the adapter chooses + * what sign convention to encode for its venue. + */ +export type Side = "buy" | "sell"; + +/** + * A single new-order intent. `size` is unsigned and in the **venue's native + * unit** (e.g. perps uses QUANTITY_SCALE bigints, futures uses int8 contract + * counts cast to bigint). Each adapter validates the unit internally. + */ +export interface OrderIntent { + side: Side; + price: bigint; + size: bigint; +} + +export interface CancelIntent { + orderId: `0x${string}`; +} + +// ─── Resting state types ──────────────────────────────────────────────────── + +/** An order resting on the venue owned by the MM. */ +export interface OwnOrder { + orderId: `0x${string}`; + price: bigint; + side: Side; + /** Unsigned size in venue-native units. */ + size: bigint; + /** Optional instrument identifier (for multi-instrument venues). */ + instrumentId?: string; +} + +export interface OwnOrderEvent { + type: "added" | "updated" | "removed"; + order?: OwnOrder; + orderId: `0x${string}`; +} + +/** Position snapshot for a single instrument. */ +export interface Position { + /** Signed: positive = long, negative = short. Venue-native units. */ + netQuantity: bigint; + entryPrice: bigint; +} + +/** A single resting depth level (one side). */ +export interface DepthLevel { + price: bigint; + /** Always positive (aggregate quantity at this price). */ + quantity: bigint; +} + +/** Snapshot of one instrument's order book. */ +export interface OrderBookSnapshot { + bids: DepthLevel[]; + asks: DepthLevel[]; +} + +// ─── Collateral & risk types ──────────────────────────────────────────────── + +/** + * Collateral / portfolio-margin snapshot for the MM's wallet at the current + * block. All values are unsigned token-decimals except `venueUnrealizedPnl`, + * which is signed (negative = mark-to-market loss). + * + * `vaultBalance` is the canonical "how much do I have" — both perps and futures + * route balanceOf through CollateralVault. + * + * `portfolioIM` and `portfolioMM` are unsigned by the engine's contract. + * Anything below MM is liquidatable; anything below IM blocks new orders. + */ +export interface CollateralSnapshot { + vaultBalance: bigint; + portfolioIM: bigint; + portfolioMM: bigint; + venueOrderMargin: bigint; + venueUnrealizedPnl: bigint; + walletTokenBalance: bigint; + nativeBalance: bigint; + collateralToken: `0x${string}`; +} + +/** + * Per-venue collateral + portfolio-margin facade used by core. Concrete + * adapters implement this against {perps DEX, futures} + the shared + * CollateralVault + PortfolioMarginEngine. + */ +export interface CollateralAccount { + /** One-shot read of all collateral / margin signals. Multicalled on chain. */ + snapshot(): Promise; + /** Cached spot price of the engine's IM shock factor (used for IM estimation). */ + imSpotShock(): Promise; + /** + * Deposit `amount` of the collateral token from the wallet into the vault. + * Adapter chooses permit vs approve+deposit; both end at vault.deposit*. + */ + deposit(amount: bigint): Promise; + /** + * Pre-trade gate: `engine.canPlaceOrder(wallet, additionalIM)`. + * Returns true iff the wallet would still be at-or-above its IM after + * adding `additionalIM` to current portfolio IM. + */ + canPlace(additionalIM: bigint): Promise; +} + +// ─── Instrument context (venue-specific hints for pricing) ────────────────── + +export interface InstrumentContext { + /** Unix seconds of delivery / expiry, if any. */ + deliveryDate?: number; + /** Contract multiplier (e.g. futures' deliveryDurationDays). */ + contractMultiplier?: bigint; + /** Strike price (options). */ + strike?: bigint; + /** Call vs put (options). */ + isCall?: boolean; + /** Underlying spot (options). */ + underlyingSpot?: bigint; +} + +// ─── Order book / matching mode ───────────────────────────────────────────── + +/** + * "limit" — orders match at any better-or-equal price (perps). + * "exact" — orders only match at the exact submitted price (futures). + * + * Drives stale-order detection in OrderExecutor. + */ +export type MatchingMode = "limit" | "exact"; + +export interface BookSource { + readonly matchingMode: MatchingMode; + /** Smallest price step on the venue. */ + tick(): Promise; + /** Snapshot of resting depth (best `depth` levels per side). */ + snapshot(opts?: { depth?: number }): Promise; +} + +// ─── Own-order source ─────────────────────────────────────────────────────── + +export type Unsubscribe = () => void; + +/** + * Per-instrument "what orders do I have resting" facade. + * + * Perps' implementation reads on-chain (`getUserOrders`) and is stateless. + * Futures' implementation maintains a local cache because the contract has + * no equivalent view; the cache is seeded by `bootstrap()` and updated by + * an internal subscription to venue events. Either way, callers only see + * `list()` / `subscribe()` / `bootstrap()`. + */ +export interface OwnOrderSource { + /** Current set of resting own orders. */ + list(): Promise; + /** Notify on adds/removes/updates. */ + subscribe(cb: (event: OwnOrderEvent) => void): Unsubscribe; + /** + * One-shot warm-up. Implementations must be idempotent: calling twice with + * the same `fromBlock` produces the same final state. + */ + bootstrap(opts?: { fromBlock?: bigint }): Promise; +} + +// ─── Venue events (decode-only) ───────────────────────────────────────────── + +/** + * Decoded venue event. Adapters emit these from `VenueEvents.subscribe`. + * + * The contract is decode-only — `subscribe` MUST NOT mutate adapter-internal + * state. State that needs to be tracked from events lives in the adapter's + * own `OwnOrderSource` cache (futures) or is recomputed on each call to + * `OwnOrderSource.list()` (perps). + */ +export type VenueEvent = + | { type: "order-created"; orderId: `0x${string}`; participant: `0x${string}`; price: bigint; side: Side; size: bigint; instrumentId?: string } + | { type: "order-updated"; orderId: `0x${string}`; participant: `0x${string}`; newSize: bigint; instrumentId?: string } + | { type: "order-cancelled"; orderId: `0x${string}`; participant: `0x${string}`; instrumentId?: string } + | { type: "order-matched"; makerOrderId: `0x${string}`; maker?: `0x${string}`; taker?: `0x${string}`; instrumentId?: string } + | { type: "position-changed"; participant: `0x${string}`; instrumentId?: string }; + +export interface VenueEvents { + subscribe(cb: (event: VenueEvent) => void): Unsubscribe; +} + +// ─── Wallet context ───────────────────────────────────────────────────────── + +export interface WalletContext { + name: string; + account: Account; + walletClient: WalletClient; +} + +// ─── Instrument adapter ───────────────────────────────────────────────────── + +/** + * Per-instrument interface. Perps and futures return a singleton from + * `VenueAdapter.getInstrument()`; an options venue would expose many. + */ +export interface InstrumentAdapter { + readonly id: string; + readonly venue: VenueAdapter; + readonly book: BookSource; + readonly ownOrders: OwnOrderSource; + + getIndexPrice(): Promise; + getPosition(): Promise; + getContext(): Promise; + + encodeCreate(intent: OrderIntent): `0x${string}`; + encodeCancel(intent: CancelIntent): `0x${string}`; + + /** + * Estimate the additional Initial Margin a new order would add to the + * wallet's portfolio IM. Used by RiskManager to call + * `engine.canPlaceOrder(wallet, sumAdditionalIM)` before placing. + * + * Mirrors the on-chain margin computation for the venue: + * - Perps: imSpotShock × notional / 1e18 + * - Futures: pricePerDay × deliveryDurationDays × marginPct / 100 + * + * Adapter computes synchronously from already-cached state (imSpotShock, + * deliveryDurationDays). Returns 0n if it can't be estimated yet. + */ + estimateOrderMargin(intent: OrderIntent): bigint; + + /** + * Estimate gas for a representative createOrder. Used by GasTracker.calibrate. + * Returns 0n on failure. + */ + estimateCreateGas(account: `0x${string}`): Promise; +} + +// ─── Venue adapter ────────────────────────────────────────────────────────── + +export type VenueKind = "perps" | "futures"; + +/** + * Per-venue interface. One per process; owns the wallet, the multicall 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. + */ +export interface VenueAdapter { + readonly kind: VenueKind; + readonly wallet: WalletContext; + readonly publicClient: PublicClient; + readonly chain: Chain; + readonly transport: Transport; + /** Contract address used for tx target and event subscription. */ + readonly address: `0x${string}`; + + readonly events: VenueEvents; + readonly account: CollateralAccount; + + /** The MM's instrument on this venue. */ + getInstrument(): Promise; + + /** + * Batch cancels/creates in one tx. Returns tx hash. Implementations route + * through the venue contract's multicall function. + */ + multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`>; +} diff --git a/market-maker/src/bookTracker.ts b/market-maker/src/core/bookTracker.ts similarity index 53% rename from market-maker/src/bookTracker.ts rename to market-maker/src/core/bookTracker.ts index 4425adc..23a23aa 100644 --- a/market-maker/src/bookTracker.ts +++ b/market-maker/src/core/bookTracker.ts @@ -1,8 +1,8 @@ import type pino from "pino"; -import type { InstrumentAdapter, OwnOrder, Unsubscribe, VenueEvent } from "./adapter.ts"; +import type { InstrumentAdapter, OwnOrder, Unsubscribe } from "./adapter.ts"; export interface BookTrackerConfig { - /** Periodic full resync interval (ms). Live events keep state fresh in between. */ + /** Periodic full resync interval (ms). */ resyncIntervalMs: number; /** Levels per side requested in the snapshot. */ snapshotDepth?: number; @@ -12,17 +12,19 @@ export interface BookTrackerConfig { * Tracks the resting order book and the MM's own orders for a single instrument. * * Sources state from: - * - periodic full snapshot via `instrument.getOrderBookSnapshot()` and `getOwnOrders()` - * - live updates via the venue's `subscribeVenueEvents` (filtered to this instrument) + * - periodic full snapshot via `instrument.book.snapshot()` and `instrument.ownOrders.list()` + * - live updates via `instrument.ownOrders.subscribe()` (own-order delta only) * - * The adapter is responsible for filtering events by `isOwn` (the MM's wallet). + * Only `OwnOrderSource` keeps adapter-internal state — BookTracker holds the + * book/own-order picture for core consumers (Quoter, Executor, Health) and + * delegates own-order state ownership entirely to the adapter. */ export class BookTracker { bestBid = 0n; bestAsk = 0n; midPrice = 0n; - /** orderId -> own order resting on the venue. */ + /** orderId -> own order. Mirrors `instrument.ownOrders` for cheap reads. */ readonly ownOrders = new Map<`0x${string}`, OwnOrder>(); private readonly bidDepth = new Map(); @@ -32,7 +34,7 @@ export class BookTracker { private readonly logger: pino.Logger; private readonly cfg: BookTrackerConfig; - private unsubscribe: Unsubscribe | null = null; + private unsubOwn: Unsubscribe | null = null; private lastResyncAt = 0; constructor(instrument: InstrumentAdapter, cfg: BookTrackerConfig, logger: pino.Logger) { @@ -43,12 +45,12 @@ export class BookTracker { async start(): Promise { await this.fullResync(); - this.subscribe(); + this.subscribeOwn(); } stop(): void { - this.unsubscribe?.(); - this.unsubscribe = null; + this.unsubOwn?.(); + this.unsubOwn = null; } /** Periodic resync if interval elapsed. Called each tick. */ @@ -64,8 +66,8 @@ export class BookTracker { private async fullResync(): Promise { const [snapshot, ownOrders] = await Promise.all([ - this.instrument.getOrderBookSnapshot({ depth: this.cfg.snapshotDepth ?? 200 }), - this.instrument.getOwnOrders(), + this.instrument.book.snapshot({ depth: this.cfg.snapshotDepth ?? 200 }), + this.instrument.ownOrders.list(), ]); this.bidDepth.clear(); @@ -93,43 +95,19 @@ export class BookTracker { ); } - private subscribe(): void { - this.unsubscribe = this.instrument.venue.subscribeVenueEvents((evt) => this.handleEvent(evt)); - } - - private handleEvent(evt: VenueEvent): void { - if ("instrumentId" in evt && evt.instrumentId !== undefined && evt.instrumentId !== this.instrument.id) { - return; - } - switch (evt.type) { - case "order-created": - if (evt.isOwn) this.ownOrders.set(evt.order.orderId, evt.order); - break; - case "order-cancelled": - if (evt.isOwn) this.ownOrders.delete(evt.orderId); - break; - case "order-updated": - if (evt.isOwn) { - const existing = this.ownOrders.get(evt.orderId); - if (existing) { - if (evt.newQuantity === 0n) this.ownOrders.delete(evt.orderId); - else existing.quantity = evt.newQuantity; - } - } - break; - case "order-matched": - if (evt.isOwn) { - this.logger.info({ makerOrderId: evt.makerOrderId }, "own order matched"); - } - break; - case "depth-changed": { - const map = evt.isBid ? this.bidDepth : this.askDepth; - if (evt.newQuantity === 0n) map.delete(evt.price); - else map.set(evt.price, evt.newQuantity); - break; + private subscribeOwn(): void { + this.unsubOwn = this.instrument.ownOrders.subscribe((evt) => { + switch (evt.type) { + case "added": + if (evt.order) this.ownOrders.set(evt.orderId, evt.order); + break; + case "updated": + if (evt.order) this.ownOrders.set(evt.orderId, evt.order); + break; + case "removed": + this.ownOrders.delete(evt.orderId); + break; } - default: - break; - } + }); } } diff --git a/market-maker/src/client.ts b/market-maker/src/core/client.ts similarity index 87% rename from market-maker/src/client.ts rename to market-maker/src/core/client.ts index 7312e2a..2d91456 100644 --- a/market-maker/src/client.ts +++ b/market-maker/src/core/client.ts @@ -1,7 +1,6 @@ import { createPublicClient, createWalletClient, defineChain, http, webSocket } from "viem"; -import type { Chain, PublicClient, WalletClient, Transport } from "viem"; +import type { Account, Chain, Hex, PublicClient, Transport, WalletClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import type { Account, Hex } from "viem"; import { arbitrum, arbitrumSepolia, base, baseSepolia, hardhat as hardhatBase } from "viem/chains"; import { ConfigError } from "./errors.ts"; @@ -53,11 +52,7 @@ export interface WalletClients { walletClient: WalletClient; } -export function createWalletFromKey( - privateKey: Hex, - chain: Chain, - transport: Transport, -): WalletClients { +export function createWalletFromKey(privateKey: Hex, chain: Chain, transport: Transport): WalletClients { const account = privateKeyToAccount(privateKey); const walletClient = createWalletClient({ account, transport, chain }); return { account, walletClient }; diff --git a/market-maker/src/core/collateralTracker.ts b/market-maker/src/core/collateralTracker.ts new file mode 100644 index 0000000..0fdc9b4 --- /dev/null +++ b/market-maker/src/core/collateralTracker.ts @@ -0,0 +1,108 @@ +import type pino from "pino"; +import Fraction from "fraction.js"; +import type { CollateralAccount, CollateralSnapshot } from "./adapter.ts"; + +export interface CollateralTrackerConfig { + /** + * Auto-deposit any wallet-held collateral into the vault on every update. + * Set true in production configs where wallet sweeps belong on chain; + * false in dev/test where you want to inspect un-deposited balance. + */ + autoDeposit: boolean; + /** Skip the deposit if `walletTokenBalance < threshold`. */ + autoDepositMinAmount: bigint; +} + +/** + * Wraps a venue's `CollateralAccount` and exposes the latest snapshot fields + * as reactive properties for the rest of core (RiskManager, HealthCheck, etc). + * + * Performs an optional automatic deposit when the wallet has un-deposited + * collateral and `autoDeposit` is enabled — replaces the legacy + * `if (... && nodeEnv === "production")` hardcoding in main.ts. + */ +export class CollateralTracker { + vaultBalance = 0n; + portfolioIM = 0n; + portfolioMM = 0n; + venueOrderMargin = 0n; + venueUnrealizedPnl = 0n; + walletTokenBalance = 0n; + nativeBalance = 0n; + collateralToken: `0x${string}` | null = null; + + /** portfolioMM / vaultBalance as a Fraction in [0, ∞). */ + utilization: Fraction = new Fraction(0n); + + private readonly account: CollateralAccount; + private readonly cfg: CollateralTrackerConfig; + private readonly logger: pino.Logger; + + constructor(account: CollateralAccount, cfg: CollateralTrackerConfig, logger: pino.Logger) { + this.account = account; + this.cfg = cfg; + this.logger = logger.child({ component: "collateral" }); + } + + async update(): Promise { + const snap = await this.account.snapshot(); + this.applySnapshot(snap); + this.logger.debug( + { + balance: this.vaultBalance.toString(), + portfolioIM: this.portfolioIM.toString(), + portfolioMM: this.portfolioMM.toString(), + utilizationPct: this.utilizationPct, + }, + "collateral tick", + ); + } + + async maybeTopUp(): Promise { + if (!this.cfg.autoDeposit) return; + if (this.walletTokenBalance < this.cfg.autoDepositMinAmount) return; + const amount = this.walletTokenBalance; + this.logger.info({ amount: amount.toString() }, "depositing wallet balance into vault"); + await this.account.deposit(amount); + await this.update(); + } + + /** Pre-trade gate: ask the engine whether `additionalIM` would still fit. */ + canPlace(additionalIM: bigint): Promise { + return this.account.canPlace(additionalIM); + } + + /** Free margin = vaultBalance − portfolioIM (clamped at 0). */ + get freeMargin(): bigint { + return this.vaultBalance > this.portfolioIM ? this.vaultBalance - this.portfolioIM : 0n; + } + + /** Maintenance ratio = portfolioMM / vaultBalance. >1 means underwater. */ + get maintenanceRatio(): Fraction { + return this.utilization; + } + + /** Utilization as integer percent. Saturates at INT32 range for safety. */ + get utilizationPct(): number { + const f = this.utilization.mul(new Fraction(100n)); + const v = (Number(f.n) / Number(f.d)) * Number(f.s); + if (!Number.isFinite(v)) return 0; + return Math.min(2_147_483_647, Math.max(-2_147_483_647, Math.round(v))); + } + + private applySnapshot(s: CollateralSnapshot): void { + this.vaultBalance = s.vaultBalance; + this.portfolioIM = s.portfolioIM; + this.portfolioMM = s.portfolioMM; + this.venueOrderMargin = s.venueOrderMargin; + this.venueUnrealizedPnl = s.venueUnrealizedPnl; + this.walletTokenBalance = s.walletTokenBalance; + this.nativeBalance = s.nativeBalance; + this.collateralToken = s.collateralToken; + + this.utilization = + this.vaultBalance > 0n + ? new Fraction(this.portfolioMM, this.vaultBalance) + : new Fraction(0n); + } +} diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts new file mode 100644 index 0000000..45f741b --- /dev/null +++ b/market-maker/src/core/config/base.ts @@ -0,0 +1,190 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import yaml from "js-yaml"; +import { type StringOptions, type TUnsafe, type TSchema, Type } from "@sinclair/typebox"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import { ConfigError } from "../errors.ts"; + +/** + * Shared config schema fragments used by per-app config modules. + * + * The architecture is deliberate: each MM app (perps, futures) builds a + * completely-typed schema from these fragments at compile time. Runtime + * validation rejects configs that don't match the *app's* schema, so we never + * hit "is this `riskAversion` defined?" branches in core code. + */ + +export const TypeEthAddress = (opt?: StringOptions) => + Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]{40}$" }) as TUnsafe<`0x${string}`>; + +export const TypeHex = (opt?: StringOptions) => + Type.String({ ...opt, pattern: "^0x[a-fA-F0-9]+$" }) as TUnsafe<`0x${string}`>; + +// Every object below is sealed (`additionalProperties: false`) so AJV rejects +// unknown keys at runtime and the YAML language server flags typos at edit +// time. New fields must be declared explicitly in the schema. +const Closed = { additionalProperties: false }; + +export const walletSchema = Type.Object( + { + privateKey: TypeHex(), + }, + Closed, +); + +export const networkSchema = Type.Object( + { + name: Type.String(), + rpcUrl: Type.String(), + // ethPriceFeed accepts an empty string for "absent" so the YAML + // `${ETH_PRICE_FEED_ADDRESS:-}` pattern works without a real value. + // Adapters treat empty as `undefined`. + ethPriceFeed: Type.Optional( + Type.Union([Type.Literal(""), TypeEthAddress()]), + ), + }, + Closed, +); + +export const riskSchema = Type.Object( + { + maxPositionSize: Type.String(), + maxUtilizationPct: Type.Number({ minimum: 0, maximum: 100, default: 80 }), + minCollateralBalance: Type.String(), + maxDailyLossUsd: Type.String(), + maxGasBudgetPerHourUsd: Type.String({ default: "50000000" }), + maxGasBudgetPerDayUsd: Type.String({ default: "500000000" }), + gasSpikeThresholdPct: Type.Number({ default: 200 }), + gasPenaltyBps: Type.Number({ default: 5 }), + urgentRequoteThresholdTicks: Type.Number({ default: 10 }), + }, + Closed, +); + +export const gasSchema = Type.Object( + { + gasCapMultiplier: Type.Number({ default: 2.0 }), + }, + Closed, +); + +export const timingSchema = Type.Object( + { + pollIntervalMs: Type.Number({ minimum: 100, default: 3000 }), + requoteThresholdTicks: Type.Number({ minimum: 0, default: 2 }), + requoteCooldownMs: Type.Number({ minimum: 0, default: 1000 }), + resyncIntervalMs: Type.Number({ minimum: 1000, default: 60000 }), + /** Tick-spacing between successive quote levels. */ + levelSpacingTicks: Type.Number({ minimum: 1, default: 1 }), + }, + Closed, +); + +export const collateralSchema = Type.Object( + { + /** Auto-deposit any wallet-held collateral into the vault on every update. */ + autoDeposit: Type.Boolean({ default: false }), + /** Skip auto-deposit if walletTokenBalance < this amount. */ + autoDepositMinAmount: Type.String({ default: "0" }), + }, + Closed, +); + +export const healthSchema = Type.Object( + { + port: Type.Number({ minimum: 0, default: 3001 }), + }, + Closed, +); + +/** + * ${VAR} expansion. Recursively walks strings in the parsed YAML and replaces + * ${NAME} with process.env.NAME. The `${NAME:-default}` form supplies a + * fallback when the variable is unset. + */ +export function expandEnv(value: unknown, env: NodeJS.ProcessEnv): unknown { + if (typeof value === "string") { + return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_match, name, def) => { + const v = env[name]; + if (v !== undefined && v !== "") return v; + if (def !== undefined) return def; + throw new ConfigError(`Environment variable "${name}" is not set`); + }); + } + if (Array.isArray(value)) { + return value.map((v) => expandEnv(v, env)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = expandEnv(v, env); + } + return out; + } + return value; +} + +export interface LoadConfigOpts { + schema: TSchema; + path?: string; + env?: NodeJS.ProcessEnv; + /** App-specific cross-field validation; throws ConfigError on failure. */ + validate?: (cfg: T) => void; +} + +export function loadConfigFromFile(opts: LoadConfigOpts): T { + const env = opts.env ?? process.env; + // Precedence: explicit opts.path > --config CLI arg > MAKER_CONFIG env var. + // CLI arg deliberately beats env so docker/CI can pass a different path + // without unsetting the inherited env. + const configPath = opts.path ?? parseConfigArg(process.argv) ?? env.MAKER_CONFIG; + if (!configPath) { + throw new ConfigError( + "No config path provided. Pass --config or set MAKER_CONFIG env var.", + ); + } + const abs = resolve(process.cwd(), configPath); + let raw: string; + try { + raw = readFileSync(abs, "utf8"); + } catch (err) { + throw new ConfigError(`Failed to read config at ${abs}: ${(err as Error).message}`); + } + + const parsed = yaml.load(raw); + const expanded = expandEnv(parsed, env); + + // `coerceTypes` lets env-interpolated strings ("false", "3001") satisfy + // boolean / number schema slots. Typos in field names still fail validation. + const ajv = new Ajv.default({ allErrors: true, useDefaults: true, coerceTypes: true }); + addFormats.default(ajv); + const validate = ajv.compile(opts.schema); + if (!validate(expanded)) { + const msgs = (validate.errors ?? []) + .map((e) => `${e.instancePath || ""} ${e.message ?? ""}`) + .join("; "); + throw new ConfigError(`Config validation failed: ${msgs}`); + } + + const cfg = expanded as T; + opts.validate?.(cfg); + return cfg; +} + +function parseConfigArg(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--config" && argv[i + 1]) return argv[i + 1]; + if (argv[i].startsWith("--config=")) return argv[i].slice("--config=".length); + } + return undefined; +} + +/** Parse a bigint-as-string value, throwing ConfigError on failure. */ +export function configBigint(value: string, field: string): bigint { + try { + return BigInt(value); + } catch { + throw new ConfigError(`Invalid bigint value for ${field}: "${value}"`); + } +} diff --git a/market-maker/src/core/env.ts b/market-maker/src/core/env.ts new file mode 100644 index 0000000..7095061 --- /dev/null +++ b/market-maker/src/core/env.ts @@ -0,0 +1,39 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Load `.env` files at startup, in priority order: + * + * 1. market-maker/.env (closest to the app, wins) + * 2. collateral-margin/.env (shared between contracts/indexer/mm) + * + * Existing `process.env` values always take precedence over file contents + * (Node's documented behaviour for `process.loadEnvFile`), so CI/docker + * runtime env still wins. + * + * Missing files are silently skipped — `.env` is a dev convenience only. + * + * The market-maker root is two directories above `src/`. The + * collateral-margin root is three directories above `src/`. We resolve + * from `import.meta.dirname` of the caller (passed in) so the paths + * work regardless of cwd. + */ +export function loadDotenvFiles(callerDir: string): void { + // src/apps/ → src/apps → src → market-maker → collateral-margin + const marketMakerRoot = resolve(callerDir, "..", "..", ".."); + const repoRoot = resolve(marketMakerRoot, ".."); + + for (const path of [ + resolve(marketMakerRoot, ".env"), + resolve(repoRoot, ".env"), + ]) { + if (existsSync(path)) { + try { + process.loadEnvFile(path); + } catch { + // ignore parse errors — runtime config validation will catch + // truly missing values. + } + } + } +} diff --git a/market-maker/src/errSerializer.ts b/market-maker/src/core/errSerializer.ts similarity index 100% rename from market-maker/src/errSerializer.ts rename to market-maker/src/core/errSerializer.ts diff --git a/market-maker/src/errors.ts b/market-maker/src/core/errors.ts similarity index 100% rename from market-maker/src/errors.ts rename to market-maker/src/core/errors.ts diff --git a/market-maker/src/gasTracker.ts b/market-maker/src/core/gasTracker.ts similarity index 92% rename from market-maker/src/gasTracker.ts rename to market-maker/src/core/gasTracker.ts index aca7046..76a4ca9 100644 --- a/market-maker/src/gasTracker.ts +++ b/market-maker/src/core/gasTracker.ts @@ -35,9 +35,7 @@ const aggregatorV3InterfaceAbi = [ export class GasTracker { currentGasPrice = 0n; - /** Fraction representation of median gas price (bigint is nicer but RollingWindow gives us bigint median so we store bigint). */ medianGasPrice = 0n; - /** Gas spike as Fraction (percentage). */ gasSpikePct: Fraction = new Fraction(0n); isGasSpiking = false; @@ -83,10 +81,7 @@ export class GasTracker { ); } - /** - * Cost of `gasUnits` gas at the current price, expressed in 6-decimal USDC units. - * Returns 0n if ETH price is unknown. - */ + /** Cost of `gasUnits` gas at the current price, expressed in 6-decimal USDC units. */ gasCostUsd(gasUnits: bigint): bigint { if (this.ethPriceUsd === 0n) return 0n; return (gasUnits * this.currentGasPrice * this.ethPriceUsd) / 10n ** 18n; @@ -110,7 +105,6 @@ export class GasTracker { cappedGasPrice(): bigint { if (this.medianGasPrice === 0n) return this.currentGasPrice; - // cap = median * capMultiplier; use bigint arithmetic with 1000-precision const multPrecision = 1000n; const mult = BigInt(Math.round(this.config.gasCapMultiplier * 1000)); const cap = (this.medianGasPrice * mult) / multPrecision; diff --git a/market-maker/src/healthcheck.ts b/market-maker/src/core/healthcheck.ts similarity index 64% rename from market-maker/src/healthcheck.ts rename to market-maker/src/core/healthcheck.ts index 23e3d56..6b35fa1 100644 --- a/market-maker/src/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -1,13 +1,13 @@ import { createServer } from "node:http"; import type { Server, ServerResponse } from "node:http"; import type pino from "pino"; +import type Fraction from "fraction.js"; import type { OracleTracker } from "./oracleTracker.ts"; import type { InventoryManager } from "./inventoryManager.ts"; +import type { CollateralTracker } from "./collateralTracker.ts"; import type { BookTracker } from "./bookTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; -import type Fraction from "fraction.js"; import type { RiskManager } from "./riskManager.ts"; -import type { MakerConfig } from "./config.ts"; import type { ErrorInfo } from "./errors.ts"; export interface ExecutorStats { @@ -18,9 +18,12 @@ export interface ExecutorStats { export interface HealthCheckOptions { port: number; - config: MakerConfig; + appName: string; + /** Sanitised, JSON-safe config slice for /health output. */ + configSummary: Record; oracle: OracleTracker; inventory: InventoryManager; + collateral: CollateralTracker; book: BookTracker; gas: GasTracker; risk: RiskManager; @@ -33,8 +36,6 @@ export interface HealthCheckOptions { * GET /health → JSON snapshot of all trackers and config (sanitised) * POST /stop → pause the main loop, cancel resting orders (via onStop) * POST /start → resume the main loop (via onStart) - * - * The bot's main loop checks `paused` and skips ticks while true. */ export class HealthCheck { private server: Server | null = null; @@ -51,24 +52,10 @@ export class HealthCheck { onStop: (() => Promise) | null = null; onStart: (() => Promise) | null = null; - private readonly port: number; - private readonly config: MakerConfig; - private readonly oracle: OracleTracker; - private readonly inventory: InventoryManager; - private readonly book: BookTracker; - private readonly gas: GasTracker; - private readonly risk: RiskManager; - private readonly logger: pino.Logger; + private readonly opts: HealthCheckOptions; constructor(opts: HealthCheckOptions) { - this.port = opts.port; - this.config = opts.config; - this.oracle = opts.oracle; - this.inventory = opts.inventory; - this.book = opts.book; - this.gas = opts.gas; - this.risk = opts.risk; - this.logger = opts.logger; + this.opts = opts; } start(): Promise { @@ -82,15 +69,16 @@ export class HealthCheck { res.writeHead(404); res.end(); } catch (err) { - this.logger.error({ err }, "server error"); + this.opts.logger.error({ err }, "server error"); res.writeHead(500); res.end(); } }); - const logger = this.logger; - this.server.listen(this.port, () => { - logger.info({ url: `http://localhost:${this.port}/health` }, "health endpoint started"); + const logger = this.opts.logger; + const port = this.opts.port; + this.server.listen(port, () => { + logger.info({ url: `http://localhost:${port}/health` }, "health endpoint started"); resolve(); }); }); @@ -108,48 +96,44 @@ export class HealthCheck { } private handleHealth(res: ServerResponse): void { + const { oracle, inventory, collateral, book, gas, risk } = this.opts; const body = JSON.stringify({ + app: this.opts.appName, status: this.status, walletAddress: this.walletAddress, lastError: this.lastError, uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), - config: { - nodeEnv: this.config.nodeEnv, - commitHash: this.config.commitHash, - logLevel: this.config.logLevel, - dryRun: this.config.dryRun, - network: this.config.network.name, - venue: { kind: this.config.venue.kind, address: this.config.venue.address }, - pricing: this.config.pricing, - sizing: this.config.sizing, - risk: this.config.risk, - gas: this.config.gas, - timing: this.config.timing, - }, + config: this.opts.configSummary, market: { - oraclePrice: this.oracle.currentPrice.toString(), - volatility: fractionToNumber(this.oracle.volatility), - bestBid: this.book.bestBid.toString(), - bestAsk: this.book.bestAsk.toString(), - ownOrders: this.book.ownOrders.size, + oraclePrice: oracle.currentPrice.toString(), + volatility: fractionToNumber(oracle.volatility), + bestBid: book.bestBid.toString(), + bestAsk: book.bestAsk.toString(), + ownOrders: book.ownOrders.size, }, inventory: { - netPosition: this.inventory.netQuantity.toString(), - collateralBalance: this.inventory.collateralBalance.toString(), - nativeBalance: this.inventory.nativeBalance.toString(), - walletTokenBalance: this.inventory.walletTokenBalance.toString(), - inventorySkew: fractionToNumber(this.inventory.inventorySkew), - utilizationPct: this.inventory.utilizationPct, + netPosition: inventory.netQuantity.toString(), + inventorySkew: fractionToNumber(inventory.inventorySkew), + }, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + venueOrderMargin: collateral.venueOrderMargin.toString(), + venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), + walletTokenBalance: collateral.walletTokenBalance.toString(), + nativeBalance: collateral.nativeBalance.toString(), + utilizationPct: collateral.utilizationPct, }, gas: { - gasGwei: (Number(this.gas.currentGasPrice) / 1e9).toFixed(2), - gasSpiking: this.gas.isGasSpiking, - gasSpikePct: fractionToNumber(this.gas.gasSpikePct).toFixed(0), + gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: gas.isGasSpiking, + gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), }, risk: { - throttled: this.risk.throttled, - throttleReason: this.risk.throttleReason, - cumulativeGasCostUsd: this.risk.cumulativeGasCostUsd.toString(), + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), }, stats: { tickCount: this.tickCount, @@ -173,7 +157,7 @@ export class HealthCheck { this.onStop() .then(() => this.respondOk(res)) .catch((err) => { - this.logger.error({ err }, "onStop callback failed"); + this.opts.logger.error({ err }, "onStop callback failed"); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "stop callback failed" })); }); @@ -189,7 +173,7 @@ export class HealthCheck { this.onStart() .then(() => this.respondOk(res)) .catch((err) => { - this.logger.error({ err }, "onStart callback failed"); + this.opts.logger.error({ err }, "onStart callback failed"); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "start callback failed" })); }); diff --git a/market-maker/src/helpers.ts b/market-maker/src/core/helpers.ts similarity index 73% rename from market-maker/src/helpers.ts rename to market-maker/src/core/helpers.ts index 1de4ddd..1202db3 100644 --- a/market-maker/src/helpers.ts +++ b/market-maker/src/core/helpers.ts @@ -1,15 +1,13 @@ /** - * Venue-agnostic helper functions used primarily by the futures adapter - * and its historical warm-up path (scanning past on-chain events for vol). - * - * All functions operate on bigint prices and plain numbers for timestamps. - * No viem or on-chain dependencies; fully unit-testable in isolation. + * Venue-agnostic helpers that don't have a clear home in another module. + * Mostly used by tests and pre-bootstrap warm-up paths; nothing here is + * on the hot trading path. */ import Fraction from "fraction.js"; import { ln, sqrt } from "./rational.ts"; -// ─── Order delta ────────────────────────────────────────────────────────────── +// ─── Order delta ──────────────────────────────────────────────────────────── export interface PricedOrder { price: bigint; @@ -19,9 +17,7 @@ export interface PricedOrder { /** * Calculate the minimal set of orders needed to transition from `currentOrders` - * to `modelledOrders`. Orders at the same price offset each other: a +5 and a -5 - * at the same price produce a zero diff and are omitted. - * + * to `modelledOrders`. Orders at the same price offset each other. * Returns orders sorted by price ascending. */ export function calculateOrders( @@ -50,27 +46,20 @@ export function calculateOrders( return result; } -// ─── Resample ───────────────────────────────────────────────────────────────── +// ─── Resample ─────────────────────────────────────────────────────────────── export interface TimedPrice { - /** Milliseconds since epoch (same as Date.now() convention). */ + /** Milliseconds since epoch. */ date: number; price: bigint; } /** * Resample irregular price ticks into fixed-interval close prices. - * - * "Close" = last observed price in each bucket. - * Missing buckets are filled with LOCF (last observation carried forward). - * - * @param prices - Raw price ticks in any order. - * @param intervalMs - Bucket width in ms (default 1 hour). + * "Close" = last observed price in each bucket. Missing buckets are filled + * with LOCF (last observation carried forward). */ -export function resampleHourlyClose( - prices: TimedPrice[], - intervalMs = 60 * 60 * 1000, -): TimedPrice[] { +export function resampleHourlyClose(prices: TimedPrice[], intervalMs = 60 * 60 * 1000): TimedPrice[] { const pts = (prices ?? []).slice().sort((a, b) => a.date - b.date); if (pts.length === 0) return []; @@ -96,7 +85,7 @@ export function resampleHourlyClose( return result; } -// ─── Realized volatility ────────────────────────────────────────────────────── +// ─── Realized volatility ──────────────────────────────────────────────────── export interface VolatilityResult { /** Stddev of log returns per sample step. 0 if fewer than 2 valid returns. */ @@ -105,18 +94,7 @@ export interface VolatilityResult { /** * Realized volatility from a price series: stddev of log returns. - * - * Uses Fraction arithmetic (via rational.ts ln/sqrt) for precision. - * Returns `{ sigmaPerStep: 0 }` for fewer than 2 valid log-return pairs. - * Returns `{ sigmaPerStep: NaN }` when sample=true and exactly 1 return - * (matches the original futures implementation: variance = 0/0 = NaN). - * - * Input prices must be positive bigints; dates must be positive finite numbers. - * Input is sorted by date before processing. - * - * @param prices - Price ticks with timestamps. - * @param sample - Use sample variance N−1 denominator (default true). - * @param precisionBits - Precision for ln/sqrt (default 48). + * Uses Fraction arithmetic via rational.ts ln/sqrt for precision. */ export function realizedVolatility( prices: TimedPrice[], @@ -143,8 +121,6 @@ export function realizedVolatility( } if (returns.length === 0) return { sigmaPerStep: 0 }; - - // Exactly 1 return with sample variance → division by (1-1)=0 → NaN if (sample && returns.length === 1) return { sigmaPerStep: Number.NaN }; let sum = new Fraction(0n); diff --git a/market-maker/src/core/inventoryManager.ts b/market-maker/src/core/inventoryManager.ts new file mode 100644 index 0000000..00045dd --- /dev/null +++ b/market-maker/src/core/inventoryManager.ts @@ -0,0 +1,67 @@ +import type pino from "pino"; +import Fraction from "fraction.js"; +import type { InstrumentAdapter } from "./adapter.ts"; +import { bigAbs } from "./math.ts"; + +export interface InventoryManagerConfig { + /** Max absolute net position; used for skew normalisation. */ + maxPositionSize: bigint; +} + +/** + * Tracks the MM's position on a single instrument. + * + * Collateral / portfolio-margin lives in `CollateralTracker` — these were + * combined in the legacy code and split for the vault era so that position + * (one instrument) and balance (one wallet, many instruments) can evolve + * independently. + */ +export class InventoryManager { + netQuantity = 0n; + entryPrice = 0n; + + /** netQuantity / maxPositionSize as a Fraction in [-1, 1]. */ + inventorySkew: Fraction = new Fraction(0n); + + private readonly instrument: InstrumentAdapter; + private readonly cfg: InventoryManagerConfig; + private readonly logger: pino.Logger; + + constructor(instrument: InstrumentAdapter, cfg: InventoryManagerConfig, logger: pino.Logger) { + this.instrument = instrument; + this.cfg = cfg; + this.logger = logger.child({ component: "inventory", instrument: instrument.id }); + } + + async update(): Promise { + const pos = await this.instrument.getPosition(); + this.netQuantity = pos.netQuantity; + this.entryPrice = pos.entryPrice; + + const maxPos = this.cfg.maxPositionSize; + if (maxPos > 0n) { + const raw = new Fraction(this.netQuantity, maxPos); + const one = new Fraction(1n); + const negOne = new Fraction(-1n); + this.inventorySkew = raw.compare(one) > 0 ? one : raw.compare(negOne) < 0 ? negOne : raw; + } else { + this.inventorySkew = new Fraction(0n); + } + + this.logger.debug( + { + net: this.netQuantity.toString(), + skew: this.inventorySkew.valueOf(), + }, + "inventory tick", + ); + } + + get hasPosition(): boolean { + return this.netQuantity !== 0n; + } + + get absPosition(): bigint { + return bigAbs(this.netQuantity); + } +} diff --git a/market-maker/src/math.ts b/market-maker/src/core/math.ts similarity index 75% rename from market-maker/src/math.ts rename to market-maker/src/core/math.ts index 5822859..21c115c 100644 --- a/market-maker/src/math.ts +++ b/market-maker/src/core/math.ts @@ -1,3 +1,10 @@ +/** + * Numeric helpers shared by core. All trading math stays in bigint or Fraction; + * `Number` is allowed only at the IO boundary (logs, JSON, ratio params from + * config). The big rule is "never round a price through a Number" — that's + * what the rational helpers in `./rational.ts` are for. + */ + import Fraction from "fraction.js"; import { ln, sqrt } from "./rational.ts"; @@ -23,7 +30,7 @@ export function roundToTick(price: bigint, tick: bigint): bigint { return remainder * 2n >= tick ? price + (tick - remainder) : price - remainder; } -/** Notional value: price * absQuantity / 10^QUANTITY_DECIMALS. */ +/** Notional value: price * absQuantity / QUANTITY_SCALE. */ export function calculateNotional(price: bigint, absQuantity: bigint): bigint { const q = bigAbs(absQuantity); return (price * q) / QUANTITY_SCALE; @@ -39,17 +46,30 @@ export function bigAbs(v: bigint): bigint { return v < 0n ? -v : v; } -/** Min / max for bigint. */ export const bigMin = (a: bigint, b: bigint) => (a < b ? a : b); export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); /** * Rolling window of bigint samples. Computes: - * - realized volatility = stddev of log returns (Fraction-precise) - * - median (bigint) + * - realized volatility = stddev of log returns (Fraction-precise) + * - median (bigint) + * + * # Volatility math + * + * r_i = ln(p_i / p_{i-1}) (log return per step) + * μ = (Σ r_i) / N + * σ² = (Σ (r_i − μ)²) / (N − 1) + * σ = sqrt(σ²) ← returned as Fraction * - * `precisionBits` controls the precision used for the internal `ln` and `sqrt` - * approximations when computing volatility; default is plenty for vol estimation. + * Notes: + * - We compute log returns as `ln(curr/prev)`, NOT `ln(curr) − ln(prev)` as + * two separate logs — Fraction.div is exact, and one ln call is half the + * work (and half the truncation error). + * - Sample variance (N−1 denominator). For N < 3 we return 0 because two + * samples produce a variance of zero whichever way you slice it. + * - `precisionBits` controls the bigint-only `ln`/`sqrt` approximations + * (see rational.ts). 48 bits is plenty for vol estimation; tune via + * constructor only when a strategy demonstrably needs more. */ export class RollingWindow { private readonly samples: bigint[] = []; @@ -76,10 +96,7 @@ export class RollingWindow { return this.samples.length > 0 ? this.samples[this.samples.length - 1] : undefined; } - /** - * Realized volatility as stddev of log returns, returned as Fraction. - * Returns 0 if fewer than 3 samples or all returns are degenerate. - */ + /** Realized volatility as stddev of log returns. 0 if fewer than 3 samples. */ volatility(): Fraction { if (this.samples.length < 3) return new Fraction(0n); @@ -88,7 +105,6 @@ export class RollingWindow { const prev = this.samples[i - 1]; const curr = this.samples[i]; if (prev > 0n && curr > 0n) { - // r = ln(curr / prev) = ln(curr) - ln(prev) const ratio = new Fraction(curr, prev); returns.push(ln(ratio, this.precisionBits)); } diff --git a/market-maker/src/oracleTracker.ts b/market-maker/src/core/oracleTracker.ts similarity index 100% rename from market-maker/src/oracleTracker.ts rename to market-maker/src/core/oracleTracker.ts diff --git a/market-maker/src/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts similarity index 56% rename from market-maker/src/orderExecutor.ts rename to market-maker/src/core/orderExecutor.ts index 6cf7d42..f5626fd 100644 --- a/market-maker/src/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -1,10 +1,5 @@ import type pino from "pino"; -import type { - DesiredQuotes, - InstrumentAdapter, - OwnOrder, - QuoteLevel, -} from "./adapter.ts"; +import type { InstrumentAdapter, OrderIntent, OwnOrder, Side } from "./adapter.ts"; import type { Quoter } from "./quoter.ts"; import type { BookTracker } from "./bookTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; @@ -25,8 +20,12 @@ export interface OrderExecutorConfig { /** * Diff desired quotes vs the resting book; cancel + place via venue multicall. * - * Cooldown, threshold, gas-spike deferral, partial-fill top-up are all here. - * Tx gas cost is reported back to RiskManager for budget enforcement. + * Stale-order detection is matching-mode-aware: + * - "exact" (futures): a resting order is stale iff its price is not in the + * desired set (each level only matches at exactly its price). + * - "limit" (perps): a resting buy is stale iff its price < worst desired + * bid; a resting sell is stale iff its price > worst desired ask. Orders + * better-than-the-grid are kept (better priority + better price). */ export class OrderExecutor { readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; @@ -63,7 +62,7 @@ export class OrderExecutor { this.logger = logger.child({ component: "executor", instrument: instrument.id }); } - async reconcile(desired: DesiredQuotes): Promise { + async reconcile(desired: OrderIntent[]): Promise { if (!this.shouldRequote(desired)) { this.logger.debug("requote skipped (within threshold or cooldown)"); return; @@ -93,17 +92,32 @@ export class OrderExecutor { return; } + // Pre-trade engine gate: ask whether the new orders' total IM still fits + // the wallet's portfolio IM budget. If not, only cancel; don't add risk. + const placeAllowed = await this.risk.canPlaceOrders(ordersToPlace, this.instrument); + const places = placeAllowed ? ordersToPlace : []; + if (!placeAllowed) { + this.logger.warn( + { wouldPlace: ordersToPlace.length }, + "engine.canPlaceOrder denied placements; cancelling stale only", + ); + } + + if (ordersToCancel.length === 0 && places.length === 0) { + return; + } + const calls: `0x${string}`[] = []; for (const order of ordersToCancel) { - calls.push(this.instrument.buildCancelCalldata(order.orderId)); + calls.push(this.instrument.encodeCancel({ orderId: order.orderId })); } - for (const level of ordersToPlace) { - calls.push(this.instrument.buildCreateCalldata(level.price, level.quantity)); + for (const intent of places) { + calls.push(this.instrument.encodeCreate(intent)); } if (this.cfg.dryRun) { this.logger.info( - { cancels: ordersToCancel.length, places: ordersToPlace.length }, + { cancels: ordersToCancel.length, places: places.length }, "DRY RUN: would send multicall batch", ); return; @@ -117,19 +131,19 @@ export class OrderExecutor { this.risk.recordGasCost(gasCost); this.stats.ordersCancelled += ordersToCancel.length; - this.stats.ordersPlaced += ordersToPlace.length; + this.stats.ordersPlaced += places.length; this.logger.info( { cancels: ordersToCancel.length, - places: ordersToPlace.length, + places: places.length, gas: receipt.gasUsed.toString(), }, "multicall batch executed", ); } catch (err) { this.logger.error( - { cancels: ordersToCancel.length, places: ordersToPlace.length, err }, + { cancels: ordersToCancel.length, places: places.length, err }, "multicall batch failed", ); throw err; @@ -145,7 +159,7 @@ export class OrderExecutor { if (orders.length === 0) return; this.logger.warn({ count: orders.length }, "cancelling all orders"); - const calls = orders.map((o) => this.instrument.buildCancelCalldata(o.orderId)); + const calls = orders.map((o) => this.instrument.encodeCancel({ orderId: o.orderId })); if (this.cfg.dryRun) { this.logger.info({ count: orders.length }, "DRY RUN: would cancel all orders"); @@ -169,10 +183,10 @@ export class OrderExecutor { } } - private shouldRequote(desired: DesiredQuotes): boolean { + private shouldRequote(desired: OrderIntent[]): boolean { if (Date.now() - this.lastRequoteAt < this.effectiveCooldownMs()) return false; - const expectedCount = desired.bids.length + desired.asks.length; + const expectedCount = desired.length; if (this.book.ownOrders.size < expectedCount) return true; if (this.hasQuantityDeficit(desired)) return true; @@ -195,50 +209,90 @@ export class OrderExecutor { return this.risk.throttled ? this.cfg.requoteThresholdTicks * 2 : this.cfg.requoteThresholdTicks; } - private findStaleOrders(desired: DesiredQuotes): OwnOrder[] { - const desiredPrices = new Set(); - for (const b of desired.bids) desiredPrices.add(b.price); - for (const a of desired.asks) desiredPrices.add(a.price); + /** + * Stale = should be cancelled. See class header for matching-mode rules. + */ + private findStaleOrders(desired: OrderIntent[]): OwnOrder[] { + const mode = this.instrument.book.matchingMode; + if (mode === "exact") return this.findStaleOrdersExact(desired); + return this.findStaleOrdersLimit(desired); + } + + private findStaleOrdersExact(desired: OrderIntent[]): OwnOrder[] { + const desiredBidPrices = new Set(); + const desiredAskPrices = new Set(); + for (const i of desired) { + (i.side === "buy" ? desiredBidPrices : desiredAskPrices).add(i.price); + } const stale: OwnOrder[] = []; for (const order of this.book.ownOrders.values()) { - if (!desiredPrices.has(order.price)) stale.push(order); + const set = order.side === "buy" ? desiredBidPrices : desiredAskPrices; + if (!set.has(order.price)) stale.push(order); } return stale; } - private findNewOrders(desired: DesiredQuotes): QuoteLevel[] { - const existing = this.aggregateOwnQuantityByPrice(); - const out: QuoteLevel[] = []; - for (const b of desired.bids) { - const have = existing.get(b.price) ?? 0n; - const deficit = b.quantity - have; - if (deficit > 0n) out.push({ price: b.price, quantity: deficit }); + private findStaleOrdersLimit(desired: OrderIntent[]): OwnOrder[] { + // For limit-mode, keep any resting order that is at-least-as-aggressive as + // the worst desired price for that side. "Aggressive" means a higher price + // for buys and a lower price for sells. + let worstDesiredBid: bigint | undefined; + let worstDesiredAsk: bigint | undefined; + for (const i of desired) { + if (i.side === "buy") { + if (worstDesiredBid === undefined || i.price < worstDesiredBid) worstDesiredBid = i.price; + } else { + if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) worstDesiredAsk = i.price; + } } - for (const a of desired.asks) { - const have = existing.get(a.price) ?? 0n; - const deficit = a.quantity - have; - if (deficit < 0n) out.push({ price: a.price, quantity: deficit }); + const stale: OwnOrder[] = []; + for (const order of this.book.ownOrders.values()) { + if (order.side === "buy") { + if (worstDesiredBid === undefined || order.price < worstDesiredBid) { + stale.push(order); + } + } else { + if (worstDesiredAsk === undefined || order.price > worstDesiredAsk) { + stale.push(order); + } + } } - return out; + return stale; } - private hasQuantityDeficit(desired: DesiredQuotes): boolean { - const existing = this.aggregateOwnQuantityByPrice(); - for (const b of desired.bids) { - const have = existing.get(b.price); - if (have !== undefined && b.quantity - have > 0n) return true; + /** + * New orders = desired levels that are missing from the resting book at + * exactly the desired price (regardless of matching mode). Limit mode's + * "we have an even better resting order" case is covered by the deficit + * check returning 0 for that level, so we don't double-place. + */ + private findNewOrders(desired: OrderIntent[]): OrderIntent[] { + const existing = this.aggregateOwnSizeByPriceSide(); + const out: OrderIntent[] = []; + for (const i of desired) { + const have = existing.get(keyOf(i.side, i.price)) ?? 0n; + const deficit = i.size - have; + if (deficit > 0n) { + out.push({ side: i.side, price: i.price, size: deficit }); + } } - for (const a of desired.asks) { - const have = existing.get(a.price); - if (have !== undefined && a.quantity - have < 0n) return true; + return out; + } + + private hasQuantityDeficit(desired: OrderIntent[]): boolean { + const existing = this.aggregateOwnSizeByPriceSide(); + for (const i of desired) { + const have = existing.get(keyOf(i.side, i.price)); + if (have !== undefined && i.size - have > 0n) return true; } return false; } - private aggregateOwnQuantityByPrice(): Map { - const m = new Map(); + private aggregateOwnSizeByPriceSide(): Map { + const m = new Map(); for (const o of this.book.ownOrders.values()) { - m.set(o.price, (m.get(o.price) ?? 0n) + o.quantity); + const k = keyOf(o.side, o.price); + m.set(k, (m.get(k) ?? 0n) + o.size); } return m; } @@ -248,3 +302,7 @@ export class OrderExecutor { return (receipt.gasUsed * receipt.effectiveGasPrice * this.gas.ethPriceUsd) / 10n ** 18n; } } + +function keyOf(side: Side, price: bigint): string { + return `${side}@${price.toString()}`; +} diff --git a/market-maker/src/pricing/effectiveSpread.ts b/market-maker/src/core/pricing/effectiveSpread.ts similarity index 55% rename from market-maker/src/pricing/effectiveSpread.ts rename to market-maker/src/core/pricing/effectiveSpread.ts index aebb885..3a9e28d 100644 --- a/market-maker/src/pricing/effectiveSpread.ts +++ b/market-maker/src/core/pricing/effectiveSpread.ts @@ -1,3 +1,54 @@ +/** + * # Effective-spread pricing + * + * Symmetric quoter that widens the spread with realised vol, gas, and inventory + * skew. Used on perps where matching is "limit" (better-or-equal). The quoter + * is symmetric around the oracle mid; inventory drives a price *shift* (skew + * offset) rather than the spread (the asymmetry comes from the offset). + * + * ## Formulas + * + * half_spread_bps = 0.5 * full_spread_bps + * + * full_spread_bps = max(min_spread, gas_floor) + * + vol_mult * σ * 1e4 + * + γ * |skew| * min_spread + * + gas_penalty * spike_pct / 100 + * + * gas_floor = round_trip_gas_cost / expected_notional * 1e4 + * skew_offset = round(γ_skew * skew * max_skew_ticks) * tick + * + * bid_mid = oracle * (1 - half_spread_bps / 1e4) - skew_offset + * ask_mid = oracle * (1 + half_spread_bps / 1e4) - skew_offset + * + * ## Units + * + * - oracle, bid_mid, ask_mid : token-decimals (USDC base units) + * - σ : per-poll log-return stddev (Fraction, dimensionless) + * - skew : netQty / maxPos in [-1, 1] (Fraction) + * - bps : basis points (1 bp = 0.01%) + * + * ## Worked example + * + * oracle = 100_000_000 (≈ $100), σ = 0.001 per poll, vol_mult = 2, γ = 0.5, + * skew = +0.4, max_skew_ticks = 20, tick = 1000, min_spread = 10 bps, no gas. + * + * vol_bps = 0.001 * 2 * 1e4 = 20 bps + * skew_inv_bps = 0.5 * 0.4 * 10 = 2 bps + * full_spread_bps= max(10, 0) + 20 + 2 = 32 bps + * half_spread_bps= 16 + * skew_offset = round(0.5 * 0.4 * 20) * 1000 = 4 * 1000 = 4000 + * bid_mid ≈ 100_000_000 * 0.9984 - 4000 = 99_836_000 + * ask_mid ≈ 100_000_000 * 1.0016 - 4000 = 100_156_000 + * + * ## References + * + * - Avellaneda & Stoikov 2008 (the spread component is the same first-order + * approximation; the inventory shift is bolted on linearly here, which is + * the simpler "symmetric quoter" used by perps. For a full A-S quoter see + * `reservationPrice.ts`.) + */ + import Fraction from "fraction.js"; import type { OracleTracker } from "../oracleTracker.ts"; import type { GasTracker } from "../gasTracker.ts"; @@ -25,12 +76,7 @@ export interface MidQuote { spreadBps: Fraction; } -/** - * Effective-spread mid pricing, ported from the legacy perps quoter. - * - * Spread = max(minSpread, gasFloor) + volMult·σ·1e4 + γ·|skew|·minSpread + gasPenalty·spike% - * Skew offset shifts both bid and ask down (long inv) or up (short inv). - */ +/** See file header for full formula and worked example. */ export function computeMidQuote(opts: { oracle: OracleTracker; gas: GasTracker; @@ -46,7 +92,13 @@ export function computeMidQuote(opts: { const spreadBps = effectiveSpreadBps({ oracle, gas, inventory, cfg, baseQuantity }); const halfSpreadBps = spreadBps.div(new Fraction(2n)); - const skewOffset = inventorySkewOffset({ inventory, oraclePrice, maxSkewTicks, tick, gamma: cfg.inventorySkewGamma }); + const skewOffset = inventorySkewOffset({ + inventory, + oraclePrice, + maxSkewTicks, + tick, + gamma: cfg.inventorySkewGamma, + }); const halfBpsBig = bpsToBigint(halfSpreadBps); const bidMid = (oraclePrice * (BPS_SCALE - halfBpsBig)) / BPS_SCALE - skewOffset; @@ -68,13 +120,15 @@ function effectiveSpreadBps(opts: { const minSpread = new Fraction(cfg.minSpreadBps); const base = gasFloor.compare(minSpread) > 0 ? gasFloor : minSpread; - // vol Fraction (stddev of log returns) * multiplier * 10000 gives bps - const vol = oracle.volatility.mul(new Fraction(Math.round(cfg.volatilityMultiplier * 1_000_000), 1_000_000)).mul( - new Fraction(10_000n), - ); + // vol Fraction (stddev of log returns) * multiplier * 10000 → bps + const vol = oracle.volatility + .mul(new Fraction(Math.round(cfg.volatilityMultiplier * 1_000_000), 1_000_000)) + .mul(new Fraction(10_000n)); const skewAbs = inventory.inventorySkew.abs(); - const inv = skewAbs.mul(minSpread); + const inv = skewAbs.mul(minSpread).mul( + new Fraction(Math.round(cfg.inventorySkewGamma * 1_000_000), 1_000_000), + ); const spike = gas.gasSpikePct; const gasPenalty = spike.compare(new Fraction(0n)) > 0 @@ -84,6 +138,11 @@ function effectiveSpreadBps(opts: { return base.add(vol).add(inv).add(gasPenalty); } +/** + * Round-trip gas cost expressed as bps of expected notional. Forms a floor + * for the spread when gas is so expensive that a fill at minSpread would + * lose money on gas alone. + */ function gasFloorBps(oracle: OracleTracker, gas: GasTracker, baseQuantity: bigint): Fraction { const rt = gas.roundTripCostUsd; if (rt === 0n) return new Fraction(0n); diff --git a/market-maker/src/core/pricing/reservationPrice.ts b/market-maker/src/core/pricing/reservationPrice.ts new file mode 100644 index 0000000..1df69d4 --- /dev/null +++ b/market-maker/src/core/pricing/reservationPrice.ts @@ -0,0 +1,142 @@ +/** + * # Reservation-price pricing (Avellaneda–Stoikov) + * + * Asymmetric quoter where the *mid* is shifted by inventory and the half-spread + * is widened by vol/gas. Used on futures where matching is "exact" — each + * level needs a distinct price to be useful, and the shift means the side we + * want to be hit gets a better price than the side we don't. + * + * ## Formulas + * + * r = S − q · γ · σ² · T (reservation price) + * + * half_spread_bps = max(min_half_bps, vol_half_bps) + gas_penalty_bps/2 · spike% + * vol_half_bps = σ · vol_mult · 1e4 / 2 + * bid = r · (1 − half_spread_bps / 1e4) + * ask = r · (1 + half_spread_bps / 1e4) + * + * q = netQuantity / QUANTITY_SCALE (signed, in "contracts") + * T = max(0, deliveryDate − now) (seconds, fallback marginCallTimeSeconds) + * σ = OracleTracker.volatility (per-poll Fraction) + * + * ## Units + * + * - S, r, bid, ask : token-decimals + * - q : contracts (Fraction) + * - γ (riskAversion): dimensionless; tune so q·γ·σ²·T at max inventory + * shifts r by ~1 tick + * - σ : per-poll log-return stddev + * - T : seconds + * + * ## Inventory direction + * + * q > 0 (long) → r < S → quotes shift DOWN, ask at lower price (eager to sell) + * q < 0 (short) → r > S → quotes shift UP, bid at higher price (eager to buy) + * + * ## Worked example + * + * S = 100_000_000, σ = 0.001, γ = 0.001, q = 50 (long 50 contracts), + * T = 86_400 (1 day to delivery), min_spread = 15 bps, vol_mult = 2.5, + * tick = 1000. + * + * adj = 50 · 0.001 · 0.000001 · 86_400 ≈ 4.32 (price units) + * r = 100_000_000 − 4.32 ≈ 99_999_995.68 → quantize to 99_999_995 + * half = max(7.5, 0.001 · 2.5 · 1e4 / 2) = max(7.5, 12.5) = 12.5 bps + * bid = 99_999_995 · 0.99875 ≈ 99_874_995 → round down to nearest tick + * ask = 99_999_995 · 1.00125 ≈ 100_124_994 → round up + * + * ## References + * + * - Avellaneda & Stoikov 2008, "High-frequency trading in a limit order book." + * Section 3.2 derives r = S − q · γ · σ² · T and shows half-spread widens + * with γ and σ; the "min_spread floor" used here is a practitioner add-on + * to handle gas costs and exchange minimums that A-S abstracts away. + * - For futures, T is bounded above by deliveryDate (margin-call point); + * after delivery the position settles and there's no more inventory risk. + */ + +import Fraction from "fraction.js"; +import { fromNumber, fromRatio, toBigint } from "../rational.ts"; +import { BPS_SCALE, QUANTITY_SCALE, roundDownToTick, roundUpToTick } from "../math.ts"; +import type { OracleTracker } from "../oracleTracker.ts"; +import type { GasTracker } from "../gasTracker.ts"; +import type { InventoryManager } from "../inventoryManager.ts"; +import type { InstrumentContext } from "../adapter.ts"; +import type { MidQuote } from "./effectiveSpread.ts"; + +export interface ReservationPriceConfig { + /** Avellaneda–Stoikov risk aversion γ. */ + riskAversion: number; + /** Fallback remaining time (seconds) when InstrumentContext.deliveryDate is absent. */ + marginCallTimeSeconds: number; + /** Floor full-spread in basis points; one-side half-spread is half this. */ + minSpreadBps: number; + /** Widens half-spread by σ × volatilityMultiplier × 1e4 / 2 (bps). */ + volatilityMultiplier: number; + /** Penalty added to spread when gas price spikes. */ + gasPenaltyBps: number; +} + +export function computeReservationMidQuote(opts: { + oracle: OracleTracker; + gas: GasTracker; + inventory: InventoryManager; + context: InstrumentContext; + cfg: ReservationPriceConfig; + tick: bigint; + nowMs?: number; +}): MidQuote { + const { oracle, gas, inventory, context, cfg, tick, nowMs = Date.now() } = opts; + const S = oracle.currentPrice; + + // Reservation price r = S − q·γ·σ²·T (file header). + const sigma = oracle.volatility; + const sigma2 = sigma.mul(sigma); + const gamma = fromNumber(cfg.riskAversion); + + const remainingSeconds: Fraction = context.deliveryDate !== undefined + ? fromNumber(Math.max(0, context.deliveryDate - nowMs / 1000)) + : fromNumber(cfg.marginCallTimeSeconds); + + const q = new Fraction(inventory.netQuantity, QUANTITY_SCALE); + const adjustment = q.mul(gamma).mul(sigma2).mul(remainingSeconds); + const rFrac = fromRatio(S).sub(adjustment); + const rBigint = toBigint(rFrac, 1n, "nearest"); + const r = rBigint > tick ? rBigint : tick; // floor at 1 tick + + // Symmetric half-spread around r; vol/gas widen it (file header). + const spreadBps = halfSpreadBps({ oracle, gas, cfg }).mul(new Fraction(2n)); + const halfBps = halfSpreadBps({ oracle, gas, cfg }); + const halfBpsBig = toBigint(halfBps, 1n, "nearest"); + + const bidRaw = (r * (BPS_SCALE - halfBpsBig)) / BPS_SCALE; + const askRaw = (r * (BPS_SCALE + halfBpsBig)) / BPS_SCALE; + + const bidMid = roundDownToTick(bidRaw > tick ? bidRaw : tick, tick); + const askMid = roundUpToTick(askRaw > tick ? askRaw : tick, tick); + + return { bidMid, askMid, spreadBps }; +} + +function halfSpreadBps(opts: { + oracle: OracleTracker; + gas: GasTracker; + cfg: ReservationPriceConfig; +}): Fraction { + const { oracle, gas, cfg } = opts; + + const minSpread = fromNumber(cfg.minSpreadBps / 2); // half of the full-spread floor + const volBps = oracle.volatility + .mul(fromNumber(cfg.volatilityMultiplier)) + .mul(new Fraction(10_000n)) + .div(new Fraction(2n)); + + const base = volBps.compare(minSpread) > 0 ? volBps : minSpread; + + const spike = gas.gasSpikePct; + const gasPenalty = spike.compare(new Fraction(0n)) > 0 + ? spike.div(new Fraction(100n)).mul(fromNumber(cfg.gasPenaltyBps / 2)) + : new Fraction(0n); + + return base.add(gasPenalty); +} diff --git a/market-maker/src/quoter.ts b/market-maker/src/core/quoter.ts similarity index 65% rename from market-maker/src/quoter.ts rename to market-maker/src/core/quoter.ts index 0d16912..db05f0c 100644 --- a/market-maker/src/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -1,6 +1,6 @@ import type pino from "pino"; import type Fraction from "fraction.js"; -import type { DesiredQuotes, InstrumentAdapter, InstrumentContext, QuoteLevel } from "./adapter.ts"; +import type { InstrumentAdapter, InstrumentContext, OrderIntent, Side } from "./adapter.ts"; import type { OracleTracker } from "./oracleTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; import type { InventoryManager } from "./inventoryManager.ts"; @@ -21,16 +21,32 @@ export interface QuoterConfig { | ({ strategy: "reservation-price" } & ReservationPriceConfig); sizing: | { strategy: "linear"; baseQuantity: bigint; numLevelsPerSide: number } - | { strategy: "geometric-taper"; baseQuantity: bigint; numLevelsPerSide: number; taperRatio: number }; + | { + strategy: "geometric-taper"; + baseQuantity: bigint; + numLevelsPerSide: number; + taperRatio: number; + }; /** Max ticks the inventory skew can shift quotes (effective-spread only). */ maxSkewTicks: number; + /** + * Spacing between successive quote levels, in ticks. App-defaulted per + * matching mode: futures (exact) wants narrow spacing (each level fills + * independently), perps (limit) wants wider spacing (deeper levels only + * fill conditional on shallower levels filling first). + */ + levelSpacingTicks: number; } /** - * Computes desired bid/ask quotes for one instrument by combining a pricing strategy - * (mid + spread) with a sizing strategy (per-level quantities). + * Computes desired bid/ask quotes for one instrument by combining a pricing + * strategy (mid + spread) with a sizing strategy (per-level quantities). * * Stateless across ticks; all state lives in the trackers it reads from. + * + * The output is a flat list of `OrderIntent`s; the executor diffs against + * resting orders. The Quoter never emits raw calldata — that lives entirely + * in the instrument adapter via `encodeCreate`. */ export class Quoter { private tick = 0n; @@ -62,7 +78,7 @@ export class Quoter { } async initialize(): Promise { - this.tick = await this.instrument.getMinTick(); + this.tick = await this.instrument.book.tick(); this.context = await this.instrument.getContext(); this.logger.info( { tick: this.tick.toString(), deliveryDate: this.context.deliveryDate }, @@ -74,10 +90,14 @@ export class Quoter { return this.tick; } - computeQuotes(): DesiredQuotes { + getContext(): InstrumentContext { + return this.context; + } + + computeQuotes(): OrderIntent[] { const oraclePrice = this.oracle.currentPrice; if (oraclePrice === 0n || this.tick === 0n) { - return { bids: [], asks: [] }; + return []; } const sizes = this.computeSizes(); @@ -103,23 +123,24 @@ export class Quoter { const { bidMid, askMid, spreadBps } = midQuote; const { quoteBid, quoteAsk } = this.risk.allowedSides(); - const bids: QuoteLevel[] = []; - const asks: QuoteLevel[] = []; + const intents: OrderIntent[] = []; + const spacing = BigInt(this.cfg.levelSpacingTicks) * this.tick; for (let level = 0; level < sizes.length; level++) { - const levelTicks = BigInt(level) * this.tick; - const qty = sizes[level]; + const offset = BigInt(level) * spacing; + const size = sizes[level]; + if (size <= 0n) continue; if (quoteBid) { - const bidRaw = bidMid - levelTicks; + const bidRaw = bidMid - offset; const bidPrice = roundDownToTick(bidRaw > 0n ? bidRaw : this.tick, this.tick); - bids.push({ price: bidPrice, quantity: qty }); + intents.push({ side: "buy", price: bidPrice, size }); } if (quoteAsk) { - const askRaw = askMid + levelTicks; + const askRaw = askMid + offset; const askPrice = roundUpToTick(askRaw, this.tick); - if (askPrice > 0n) asks.push({ price: askPrice, quantity: -qty }); + if (askPrice > 0n) intents.push({ side: "sell", price: askPrice, size }); } } @@ -127,15 +148,13 @@ export class Quoter { { strategy: this.cfg.pricing.strategy, spreadBps: fractionToString(spreadBps), - bidLevels: bids.length, - askLevels: asks.length, - bidTop: bids[0]?.price.toString(), - askTop: asks[0]?.price.toString(), + bids: intents.filter((i) => i.side === "buy").length, + asks: intents.filter((i) => i.side === "sell").length, }, "quotes computed", ); - return { bids, asks }; + return intents; } private computeSizes(): bigint[] { @@ -143,11 +162,24 @@ export class Quoter { if (s.strategy === "linear") { return linearSizes(s.baseQuantity, s.numLevelsPerSide); } - return geometricTaperSizes(s.baseQuantity * BigInt(s.numLevelsPerSide), s.taperRatio, s.numLevelsPerSide); + return geometricTaperSizes( + s.baseQuantity * BigInt(s.numLevelsPerSide), + s.taperRatio, + s.numLevelsPerSide, + ); } } +/** Diagnostic-only Fraction → string. Never used in trading math. */ function fractionToString(f: Fraction): string { - // safe approximation for diagnostics; never used in trading math return (Number(f.n) / Number(f.d)).toFixed(2); } + +/** Convenience helper. */ +export function bidIntents(intents: OrderIntent[]): OrderIntent[] { + return intents.filter((i) => i.side === "buy"); +} +export function askIntents(intents: OrderIntent[]): OrderIntent[] { + return intents.filter((i) => i.side === "sell"); +} +export const isBuy = (s: Side): boolean => s === "buy"; diff --git a/market-maker/src/rational.ts b/market-maker/src/core/rational.ts similarity index 77% rename from market-maker/src/rational.ts rename to market-maker/src/core/rational.ts index 04961f2..56cbcc0 100644 --- a/market-maker/src/rational.ts +++ b/market-maker/src/core/rational.ts @@ -25,11 +25,6 @@ export function bigintSqrtFloor(n: bigint): bigint { return x; } -/** Absolute value of a bigint. */ -function babs(x: bigint): bigint { - return x < 0n ? -x : x; -} - /** * Square root of a non-negative Fraction with `precisionBits` fractional bits. * @@ -50,7 +45,6 @@ export function sqrt(x: Fraction, precisionBits = 64): Fraction { // sqrt(n/d) * 2^b = sqrt(n * 4^b / d) = sqrt(n * 4^b * d) / d const radicand = x.n * scaleSquared * x.d; const root = bigintSqrtFloor(radicand); - // Sign of x is non-negative; n/d in fraction.js stores absolute values with .s return new Fraction(root, scale * x.d); } @@ -63,30 +57,24 @@ export function sqrt(x: Fraction, precisionBits = 64): Fraction { * 2. For y in [1/2, 2], let u = (y - 1) / (y + 1), |u| <= 1/3. * ln(y) = 2 * (u + u^3/3 + u^5/5 + ...) * Series converges geometrically; truncate when terms drop below precision. - * 3. ln(2) is computed once at the requested precision via the same atanh series - * applied to (3-1)/(3+1) and the identity ln(2) = ln(4/3) + ln(3/2), - * but it's simpler to use ln(2) = -ln(1/2) computed by the same series with y=1/2. + * 3. ln(2) is computed once at the requested precision via the same series at y=1/2. */ export function ln(x: Fraction, precisionBits = 64): Fraction { if (x.s <= 0 || x.n === 0n) { throw new RangeError("ln: argument must be positive"); } - // Step 1: reduce to y in [1/2, 2] by extracting powers of 2. - // We compare numerator vs denominator * 2^k. let { n, d } = x; let k = 0n; - // Halve while x >= 2 <=> n >= 2*d while (n >= 2n * d) { d *= 2n; k += 1n; } - // Double while x < 1/2 <=> n*2 < d <=> d > 2*n while (d > 2n * n) { n *= 2n; k -= 1n; } - const y = new Fraction(n, d); // y in [1/2, 2] + const y = new Fraction(n, d); const lnY = atanhSeries(y, precisionBits); if (k === 0n) return lnY; @@ -96,27 +84,22 @@ export function ln(x: Fraction, precisionBits = 64): Fraction { } /** - * Computes ln(y) for y in [1/2, 2] using the atanh series: - * ln(y) = 2 * sum_{i=0..inf} u^(2i+1) / (2i+1), where u = (y-1)/(y+1) - * - * |u| <= 1/3 in this interval, so the series converges quickly. - * Truncates when the next term drops below 2^-precisionBits relative to result. + * ln(y) for y in [1/2, 2] via atanh series: + * ln(y) = 2 * Σ u^(2i+1) / (2i+1), u = (y-1)/(y+1) + * |u| <= 1/3 here, so it converges quickly. */ function atanhSeries(y: Fraction, precisionBits: number): Fraction { - const u = y.sub(1).div(y.add(1)); // (y-1)/(y+1) + const u = y.sub(1).div(y.add(1)); if (u.n === 0n) return new Fraction(0n); const u2 = u.mul(u); - let term = u; // u^1 / 1 + let term = u; let sum = term; - // tolerance: when |term| < 2^-precisionBits we stop const tolDen = 1n << BigInt(precisionBits); - // term threshold as Fraction: 1/2^precisionBits const tolerance = new Fraction(1n, tolDen); let i = 1n; while (true) { - // next term = previous * u^2 * (2i-1)/(2i+1) const idx2 = 2n * i + 1n; term = term.mul(u2).mul(new Fraction(2n * i - 1n, idx2)); sum = sum.add(term); @@ -131,7 +114,7 @@ const ln2Cache = new Map(); function ln2Cached(precisionBits: number): Fraction { const cached = ln2Cache.get(precisionBits); if (cached) return cached; - // ln(2) = -ln(1/2). y = 1/2 reduces to itself with k=0, so atanhSeries handles it. + // ln(2) = -ln(1/2). y = 1/2 stays in [1/2, 2] with k=0. const v = atanhSeries(new Fraction(1n, 2n), precisionBits).neg(); ln2Cache.set(precisionBits, v); return v; @@ -139,7 +122,6 @@ function ln2Cached(precisionBits: number): Fraction { /** * Convert a Fraction to bigint at a given scale, using nearest-even rounding. - * E.g. toBigint(Fraction(7, 3), 1n) ~ 2n (since 7/3 = 2.33...). * * `scale` is the integer denominator the result will be expressed against, * i.e. result represents `value * scale` rounded to the nearest integer. @@ -154,9 +136,8 @@ export function toBigint(value: Fraction, scale: bigint = 1n, mode: "nearest" | if (mode === "ceil") { return -floorDiv(-num, den); } - // nearest-even (banker's rounding) const q = floorDiv(num, den); - const r = num - q * den; // 0 <= r < den + const r = num - q * den; const twice = 2n * r; if (twice < den) return q; if (twice > den) return q + 1n; @@ -186,5 +167,5 @@ export function fromNumber(value: number): Fraction { return new Fraction(value); } -/** Re-export Fraction for convenience so callers don't need a separate import. */ +/** Re-export Fraction for convenience. */ export { default as Fraction } from "fraction.js"; diff --git a/market-maker/src/riskManager.ts b/market-maker/src/core/riskManager.ts similarity index 53% rename from market-maker/src/riskManager.ts rename to market-maker/src/core/riskManager.ts index edb665b..0210b9f 100644 --- a/market-maker/src/riskManager.ts +++ b/market-maker/src/core/riskManager.ts @@ -1,4 +1,45 @@ +/** + * # RiskManager + * + * MM-side risk gating. Three responsibilities: + * + * 1. Halt — stop quoting and cancel all orders. Conditions: portfolio MM + * breach, daily loss limit, minimum collateral floor. + * 2. Throttle — slow down quoting (longer cooldown, wider requote threshold). + * Conditions: hourly/daily gas budget exceeded. + * 3. Side gating — refuse to add to a side already at max position. + * + * # Relationship to on-chain margin + * + * The canonical margin computation is `PortfolioMarginEngine.computePortfolioIM/MM` + * on chain. We DO NOT replicate the 4-scenario stress test here; we read the + * outputs through `CollateralTracker` and use them as inputs. + * + * Pre-trade gate uses the engine view directly: + * + * canPlaceOrders(intents) := engine.canPlaceOrder(wallet, Σ estimateOrderMargin(i)) + * + * `estimateOrderMargin` mirrors the on-chain per-product order-margin formula + * for the venue (see InstrumentAdapter.estimateOrderMargin docstring). If the + * estimate is wrong on the high side we waste a few bps of quoting capacity + * by being too conservative. If wrong on the low side, the tx may revert on + * place — acceptable, the chain is the final authority. + * + * # Safety margin policy + * + * - Halt at portfolioMM breach (vaultBalance < portfolioMM). Strict — once + * this fires, we are technically liquidatable on chain. + * - Halt at minCollateralBalance (config floor). Operational guardrail. + * - Halt at maxDailyLossUsd: net of vaultBalance change since midnight + gas. + * - Throttle at hourly/daily gas budget (recoverable; resumes when window + * rolls over). + * + * Counters reset at UTC midnight via `checkDayRollover`. + */ + import type pino from "pino"; +import type { InstrumentAdapter, OrderIntent } from "./adapter.ts"; +import type { CollateralTracker } from "./collateralTracker.ts"; import type { InventoryManager } from "./inventoryManager.ts"; import type { GasTracker } from "./gasTracker.ts"; import type { OracleTracker } from "./oracleTracker.ts"; @@ -17,12 +58,6 @@ export interface RiskManagerConfig { maxGasBudgetPerDayUsd: bigint; } -/** - * Risk halts (stop quoting and cancel) and throttles (slow down quoting). - * - * Halts are recoverable on the next tick once the underlying condition clears. - * Daily PnL counters reset at midnight UTC. - */ export class RiskManager { halted = false; haltReason: ErrorInfo | null = null; @@ -39,6 +74,7 @@ export class RiskManager { private readonly cfg: RiskManagerConfig; private readonly inventory: InventoryManager; + private readonly collateral: CollateralTracker; private readonly gas: GasTracker; private readonly oracle: OracleTracker; private readonly logger: pino.Logger; @@ -46,12 +82,14 @@ export class RiskManager { constructor( cfg: RiskManagerConfig, inventory: InventoryManager, + collateral: CollateralTracker, gas: GasTracker, oracle: OracleTracker, logger: pino.Logger, ) { this.cfg = cfg; this.inventory = inventory; + this.collateral = collateral; this.gas = gas; this.oracle = oracle; this.logger = logger.child({ component: "risk" }); @@ -59,9 +97,9 @@ export class RiskManager { this.gasDailyBudget = new RollingBudget(24 * 60 * 60 * 1000); } - /** Snapshot starting collateral; call once after first inventory update. */ + /** Snapshot starting collateral; call once after first collateral update. */ initialize(): void { - this.startOfDayBalance = this.inventory.collateralBalance; + this.startOfDayBalance = this.collateral.vaultBalance; this.startOfDayTimestamp = Date.now(); } @@ -75,27 +113,34 @@ export class RiskManager { check(): boolean { this.checkDayRollover(); - if (this.inventory.collateralBalance < this.cfg.minCollateralBalance) { - this.halted = true; - this.haltReason = { + if (this.collateral.vaultBalance < this.cfg.minCollateralBalance) { + return this.halt({ message: "collateral below minimum", - balance: this.inventory.collateralBalance.toString(), + balance: this.collateral.vaultBalance.toString(), min: this.cfg.minCollateralBalance.toString(), - }; - this.logger.error(this.haltReason, "HALT: collateral below minimum"); - return false; + }); + } + + // Portfolio MM is the on-chain liquidation threshold. If we're below it, + // we're already at risk and should stop adding orders immediately. + if ( + this.collateral.portfolioMM > 0n && + this.collateral.vaultBalance < this.collateral.portfolioMM + ) { + return this.halt({ + message: "portfolio MM breached", + balance: this.collateral.vaultBalance.toString(), + portfolioMM: this.collateral.portfolioMM.toString(), + }); } const truePnl = this.truePnl(); if (truePnl < 0n && bigAbs(truePnl) > this.cfg.maxDailyLossUsd) { - this.halted = true; - this.haltReason = { + return this.halt({ message: "daily loss limit breached", pnl: truePnl.toString(), max: this.cfg.maxDailyLossUsd.toString(), - }; - this.logger.error(this.haltReason, "HALT: daily loss limit breached"); - return false; + }); } this.halted = false; @@ -125,14 +170,30 @@ export class RiskManager { } /** - * Sides allowed to quote. Respects position cap and stops quoting at high utilization - * (only the side that reduces exposure is allowed). + * Pre-trade engine gate. Sums per-order IM estimates and asks the engine + * whether the wallet can place all of them in one batch. + * + * Returns true on empty input. + */ + async canPlaceOrders(intents: OrderIntent[], instrument: InstrumentAdapter): Promise { + if (intents.length === 0) return true; + let total = 0n; + for (const i of intents) { + total += instrument.estimateOrderMargin(i); + } + if (total === 0n) return true; + return this.collateral.canPlace(total); + } + + /** + * Sides allowed to quote. Respects position cap and stops quoting at high + * utilization (only the side that reduces exposure is allowed). */ allowedSides(): { quoteBid: boolean; quoteAsk: boolean } { const maxPos = this.cfg.maxPositionSize; const net = this.inventory.netQuantity; - if (this.inventory.utilizationPct > this.cfg.maxUtilizationPct) { + if (this.collateral.utilizationPct > this.cfg.maxUtilizationPct) { if (net > 0n) return { quoteBid: false, quoteAsk: true }; if (net < 0n) return { quoteBid: true, quoteAsk: false }; return { quoteBid: false, quoteAsk: false }; @@ -144,9 +205,16 @@ export class RiskManager { }; } + private halt(reason: ErrorInfo): false { + this.halted = true; + this.haltReason = reason; + this.logger.error(reason, `HALT: ${reason.message}`); + return false; + } + /** Net PnL today including gas. Negative = loss. */ private truePnl(): bigint { - const balanceDelta = this.inventory.collateralBalance - this.startOfDayBalance; + const balanceDelta = this.collateral.vaultBalance - this.startOfDayBalance; return balanceDelta - this.cumulativeGasCostUsd; } @@ -157,7 +225,7 @@ export class RiskManager { const midnightMs = todayMidnight.getTime(); if (this.startOfDayTimestamp < midnightMs && now >= midnightMs) { - this.startOfDayBalance = this.inventory.collateralBalance; + this.startOfDayBalance = this.collateral.vaultBalance; this.startOfDayTimestamp = now; this.cumulativeGasCostUsd = 0n; this.logger.info("day rollover: PnL counters reset"); diff --git a/market-maker/src/core/runner.ts b/market-maker/src/core/runner.ts new file mode 100644 index 0000000..c9bafe5 --- /dev/null +++ b/market-maker/src/core/runner.ts @@ -0,0 +1,181 @@ +import type pino from "pino"; +import type { CollateralTracker } from "./collateralTracker.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { BookTracker } from "./bookTracker.ts"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { Quoter } from "./quoter.ts"; +import type { OrderExecutor } from "./orderExecutor.ts"; +import type { HealthCheck } from "./healthcheck.ts"; +import type { InstrumentAdapter } from "./adapter.ts"; +import { toErrorInfo } from "./errSerializer.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const BASE_ERROR_DELAY_MS = 5_000; +const MAX_ERROR_DELAY_MS = 3 * 60_000; + +export interface RunnerOpts { + pollIntervalMs: number; + instrument: InstrumentAdapter; + oracle: OracleTracker; + gas: GasTracker; + book: BookTracker; + inventory: InventoryManager; + collateral: CollateralTracker; + risk: RiskManager; + quoter: Quoter; + executor: OrderExecutor; + health: HealthCheck; + logger: pino.Logger; +} + +/** + * Boots the trackers (with retry/backoff) and then runs the per-tick loop: + * + * 1. update oracle / gas / book / inventory / collateral + * 2. optionally auto-deposit wallet collateral into the vault + * 3. risk.check() — if not ok, cancelAll and skip + * 4. quoter.computeQuotes() → executor.reconcile(desired) + * + * `executor.reconcile` itself runs the engine pre-trade gate via + * `risk.canPlaceOrders`, so the runner doesn't need to do it explicitly. + * + * Two backoff regimes: + * - Initialization: exponential backoff from BASE_ERROR_DELAY_MS up to MAX. + * - Steady state: same exponential backoff after each tick error, reset on + * successful tick. + */ +export async function runMakerLoop(opts: RunnerOpts): Promise { + const { pollIntervalMs, instrument, oracle, gas, book, inventory, collateral, risk, quoter, executor, health, logger } = opts; + const mmAddress = instrument.venue.wallet.account.address; + + health.executorStats = executor.stats; + health.walletAddress = mmAddress; + + health.onStop = async () => { + logger.info("stop requested via API, cancelling orders"); + await executor.cancelAll(); + book.stop(); + }; + health.onStart = async () => { + logger.info("start requested via API, re-initializing"); + await book.start(); + await oracle.update(); + await gas.update(); + await inventory.update(); + await collateral.update(); + }; + + await health.start(); + + for (let attempt = 1; ; attempt++) { + try { + await quoter.initialize(); + await gas.calibrate(() => instrument.estimateCreateGas(mmAddress)); + await book.start(); + await oracle.update(); + await gas.update(); + await inventory.update(); + await collateral.update(); + risk.initialize(); + health.status = "running"; + health.lastError = null; + break; + } catch (err) { + health.status = "init-error"; + health.lastError = toErrorInfo(err); + const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); + logger.warn({ err, attempt, retryInMs: delay }, "initialization failed, retrying"); + await sleep(delay); + } + } + + logger.info("initialization complete, entering main loop"); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down…"); + try { + await executor.cancelAll(); + } catch (err) { + logger.error({ err }, "failed to cancel orders during shutdown"); + } + book.stop(); + await health.stop(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + let consecutiveErrors = 0; + while (!shuttingDown) { + if (health.paused) { + await sleep(pollIntervalMs); + continue; + } + + try { + await oracle.update(); + await gas.update(); + await book.refresh(); + await inventory.update(); + await collateral.update(); + + try { + await collateral.maybeTopUp(); + } catch (err) { + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "failed to top up collateral"); + } + + logger.info( + { + oracle: oracle.currentPrice.toString(), + bid: book.bestBid.toString(), + ask: book.bestAsk.toString(), + pos: inventory.netQuantity.toString(), + vaultBalance: collateral.vaultBalance.toString(), + orders: book.ownOrders.size, + }, + "tick", + ); + + const ok = risk.check(); + if (!ok) { + health.status = "error"; + health.lastError = risk.haltReason; + consecutiveErrors++; + try { + await executor.cancelAll(); + } catch (err) { + health.lastError = toErrorInfo(err); + logger.error({ err }, "failed to cancel orders after risk halt"); + } + } else { + const desired = quoter.computeQuotes(); + await executor.reconcile(desired); + health.status = "running"; + health.lastError = null; + consecutiveErrors = 0; + } + } catch (err) { + consecutiveErrors++; + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "tick error"); + } + + health.tickCount++; + health.lastTickAt = Date.now(); + const delay = + consecutiveErrors > 0 + ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) + : pollIntervalMs; + await sleep(delay); + } +} diff --git a/market-maker/src/core/sizing/geometricTaper.ts b/market-maker/src/core/sizing/geometricTaper.ts new file mode 100644 index 0000000..4988f54 --- /dev/null +++ b/market-maker/src/core/sizing/geometricTaper.ts @@ -0,0 +1,62 @@ +/** + * # Geometric-taper sizing + * + * Each successive level is `ratio` of the previous one. Total inventory + * across all levels equals `totalQuantity`. + * + * q_k = totalQuantity · ratio^k · (1 − ratio) / (1 − ratio^N) + * + * The `(1 − ratio) / (1 − ratio^N)` factor normalises so that Σq_k = totalQuantity + * (geometric series sum). For ratio = 0.5 the sizes are + * { Q/2, Q/4, Q/8, ... } / (1 − 0.5^N) ≈ { Q/2, Q/4, Q/8, ... } + * for large N. As ratio → 1 sizes flatten toward Q/N each. + * + * Used on futures where matching is "exact" — every level needs a distinct + * fill probability profile, and the front level should be the largest + * because it has the only direct fill opportunity. + * + * ## Edge cases + * + * ratio = 0 → throws (degenerate; only level 0 has any size) + * ratio = 1 → throws (geometric-series formula divides by zero; + * use linearSizes if you want flat) + * numLevels < 1 → throws + * + * ## Worked example + * + * totalQuantity = 600_000_000 (600 USDC), ratio = 0.6, numLevels = 4. + * + * powers = { 1, 0.6, 0.36, 0.216 } + * denom = 2.176 + * q_0 = 600M · 1 / 2.176 ≈ 275_735_294 + * q_1 = 600M · 0.6 / 2.176 ≈ 165_441_176 + * q_2 = 600M · 0.36 / 2.176 ≈ 99_264_705 + * q_3 = 600M · 0.216 / 2.176 ≈ 59_558_823 + * sum = 599_999_998 (rounds to total within 1 unit per level) + */ + +import Fraction from "fraction.js"; +import { toBigint } from "../rational.ts"; + +export function geometricTaperSizes(totalQuantity: bigint, ratio: number, numLevels: number): bigint[] { + if (numLevels < 1) throw new Error("numLevels must be >= 1"); + if (!(ratio > 0 && ratio < 1)) throw new Error("ratio must be in (0, 1)"); + const r = new Fraction(Math.round(ratio * 1_000_000), 1_000_000); + const one = new Fraction(1n); + // ratio^k for k in [0, numLevels) + const powers: Fraction[] = []; + let p = one; + for (let k = 0; k < numLevels; k++) { + powers.push(p); + p = p.mul(r); + } + let denom = new Fraction(0n); + for (const x of powers) denom = denom.add(x); + const totalQ = new Fraction(totalQuantity); + const out: bigint[] = []; + for (const pk of powers) { + const qFrac = totalQ.mul(pk).div(denom); + out.push(toBigint(qFrac, 1n, "floor")); + } + return out; +} diff --git a/market-maker/src/sizing/linear.ts b/market-maker/src/core/sizing/linear.ts similarity index 50% rename from market-maker/src/sizing/linear.ts rename to market-maker/src/core/sizing/linear.ts index ec989be..5afc8d9 100644 --- a/market-maker/src/sizing/linear.ts +++ b/market-maker/src/core/sizing/linear.ts @@ -1,6 +1,11 @@ /** - * Linear ladder sizing: level k gets (k+1) * baseQuantity. + * Linear ladder sizing: level k gets `(k+1) * baseQuantity`. + * * level 0 = base, level 1 = 2*base, level 2 = 3*base, ... + * + * Used on perps where the quoter is symmetric and matching is "limit" + * (better-or-equal). Deeper levels are larger because they have higher + * fill probability conditional on level k-1 having fully filled. */ export function linearSizes(baseQuantity: bigint, numLevels: number): bigint[] { const out: bigint[] = []; diff --git a/market-maker/src/core/vaultDeposit.ts b/market-maker/src/core/vaultDeposit.ts new file mode 100644 index 0000000..49c0089 --- /dev/null +++ b/market-maker/src/core/vaultDeposit.ts @@ -0,0 +1,232 @@ +import { erc20Abi } from "viem"; +import type { Account, Chain, PublicClient, WalletClient } from "viem"; +import type pino from "pino"; + +/** + * Vault-deposit helper shared by both adapters. + * + * Both perps and futures are migrated to `CollateralVault`. The MM never calls + * the per-product `addCollateralWithPermit` / `addMargin` paths anymore — it + * deposits directly to the vault, and the venue contracts read balances via + * `vault.balanceOf(user)`. + * + * Two paths are supported: + * + * 1. Permit (preferred). If the collateral token implements EIP-2612 we sign + * a permit and call `vault.depositForPermit(recipient, amount, deadline, + * v, r, s)` in one tx. Domain is discovered via EIP-5267 if the token + * implements it, else falls back to `name()` + `version()`. + * + * 2. Approve + deposit (fallback). Two txs: `erc20.approve(vault, amount)` + * then `vault.deposit(amount)`. Used when (1) fails for any reason — the + * detection is best-effort, not exhaustive. + */ + +const ierc20PermitAbi = [ + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "nonces", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "address", name: "spender", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + { internalType: "uint8", name: "v", type: "uint8" }, + { internalType: "bytes32", name: "r", type: "bytes32" }, + { internalType: "bytes32", name: "s", type: "bytes32" }, + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +const ierc5267Abi = [ + { + inputs: [], + name: "eip712Domain", + outputs: [ + { internalType: "bytes1", name: "fields", type: "bytes1" }, + { internalType: "string", name: "name", type: "string" }, + { internalType: "string", name: "version", type: "string" }, + { internalType: "uint256", name: "chainId", type: "uint256" }, + { internalType: "address", name: "verifyingContract", type: "address" }, + { internalType: "bytes32", name: "salt", type: "bytes32" }, + { internalType: "uint256[]", name: "extensions", type: "uint256[]" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +const tokenVersionAbi = [ + { + inputs: [], + name: "version", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const vaultAbi = [ + { + inputs: [{ internalType: "uint256", name: "amount", type: "uint256" }], + name: "deposit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + { internalType: "uint8", name: "v", type: "uint8" }, + { internalType: "bytes32", name: "r", type: "bytes32" }, + { internalType: "bytes32", name: "s", type: "bytes32" }, + ], + name: "depositForPermit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +const permitTypes = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +export interface DepositToVaultOpts { + publicClient: PublicClient; + walletClient: WalletClient; + account: Account; + chain: Chain; + vaultAddress: `0x${string}`; + collateralToken: `0x${string}`; + amount: bigint; + logger: pino.Logger; +} + +/** Deposit `amount` of `collateralToken` from `account` into `vaultAddress`. */ +export async function depositToVault(opts: DepositToVaultOpts): Promise { + const { amount, logger } = opts; + if (amount <= 0n) return; + logger.info({ amount: amount.toString(), vault: opts.vaultAddress }, "depositing to vault"); + + const usePermit = await tryPermitDeposit(opts); + if (usePermit) return; + + // Fallback: approve + deposit. + await approveAndDeposit(opts); +} + +async function tryPermitDeposit(opts: DepositToVaultOpts): Promise { + const { publicClient, walletClient, account, chain, vaultAddress, collateralToken, amount, logger } = opts; + + // Discover permit domain. If nonces() reverts, the token doesn't implement + // EIP-2612 — bail out cleanly. + const owner = account.address; + const reads = await publicClient.multicall({ + allowFailure: true, + contracts: [ + { address: collateralToken, abi: erc20Abi, functionName: "name" }, + { address: collateralToken, abi: tokenVersionAbi, functionName: "version" }, + { address: collateralToken, abi: ierc20PermitAbi, functionName: "nonces", args: [owner] }, + { address: collateralToken, abi: ierc5267Abi, functionName: "eip712Domain" }, + ], + }); + const [nameResult, versionResult, nonceResult, domainResult] = reads; + + if (nonceResult.status === "failure") { + logger.debug("token does not implement EIP-2612 (nonces reverted)"); + return false; + } + + let domain: { name: string; version: string; chainId: number; verifyingContract: `0x${string}` }; + if (domainResult.status === "success") { + const [, dName, dVersion, dChainId, dVerifyingContract] = domainResult.result; + domain = { name: dName, version: dVersion, chainId: Number(dChainId), verifyingContract: dVerifyingContract }; + } else { + if (nameResult.status === "failure") { + logger.warn({ err: nameResult.error }, "token has no name(); using approve fallback"); + return false; + } + domain = { + name: nameResult.result, + version: versionResult.status === "success" ? versionResult.result || "1" : "1", + chainId: chain.id, + verifyingContract: collateralToken, + }; + } + + const nonce = nonceResult.result; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); + + const signature = await walletClient.signTypedData({ + account, + domain, + types: permitTypes, + primaryType: "Permit", + message: { owner, spender: vaultAddress, value: amount, nonce, deadline }, + }); + + const r = `0x${signature.slice(2, 66)}` as `0x${string}`; + const s = `0x${signature.slice(66, 130)}` as `0x${string}`; + const v = Number.parseInt(signature.slice(130, 132), 16); + + try { + const hash = await walletClient.writeContract({ + address: vaultAddress, + abi: vaultAbi, + functionName: "depositForPermit", + args: [owner, amount, deadline, v, r, s], + account, + chain, + }); + await publicClient.waitForTransactionReceipt({ hash }); + logger.info({ amount: amount.toString() }, "vault deposit (permit) confirmed"); + return true; + } catch (err) { + logger.warn({ err }, "depositForPermit failed; falling back to approve+deposit"); + return false; + } +} + +async function approveAndDeposit(opts: DepositToVaultOpts): Promise { + const { publicClient, walletClient, account, chain, vaultAddress, collateralToken, amount, logger } = opts; + + logger.info({ amount: amount.toString() }, "approving vault to spend collateral"); + const approveHash = await walletClient.writeContract({ + address: collateralToken, + abi: erc20Abi, + functionName: "approve", + args: [vaultAddress, amount], + account, + chain, + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + + const depositHash = await walletClient.writeContract({ + address: vaultAddress, + abi: vaultAbi, + functionName: "deposit", + args: [amount], + account, + chain, + }); + await publicClient.waitForTransactionReceipt({ hash: depositHash }); + logger.info({ amount: amount.toString() }, "vault deposit (approve+deposit) confirmed"); +} diff --git a/market-maker/src/wallet.ts b/market-maker/src/core/wallet.ts similarity index 74% rename from market-maker/src/wallet.ts rename to market-maker/src/core/wallet.ts index 41d2764..44a9298 100644 --- a/market-maker/src/wallet.ts +++ b/market-maker/src/core/wallet.ts @@ -1,26 +1,17 @@ -import type { Account, Chain, Hex, Transport, WalletClient } from "viem"; +import type { Chain, Hex, Transport } from "viem"; import { ConfigError } from "./errors.ts"; import { createWalletFromKey } from "./client.ts"; - -export interface WalletContext { - name: string; - account: Account; - walletClient: WalletClient; -} +import type { WalletContext } from "./adapter.ts"; /** * Resolves named wallets declared in config into live viem wallet contexts. - * One `privateKeyToAccount` call per name — shared when multiple venues reference - * the same wallet name. + * One `privateKeyToAccount` call per name — shared when multiple venues + * reference the same wallet name. */ export class WalletRegistry { private readonly contexts = new Map(); - constructor( - walletConfigs: Record, - chain: Chain, - transport: Transport, - ) { + constructor(walletConfigs: Record, chain: Chain, transport: Transport) { for (const [name, cfg] of Object.entries(walletConfigs)) { const { account, walletClient } = createWalletFromKey(cfg.privateKey, chain, transport); this.contexts.set(name, { name, account, walletClient }); diff --git a/market-maker/src/index.ts b/market-maker/src/index.ts deleted file mode 100644 index 807e5ec..0000000 --- a/market-maker/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -export * from "./adapter.ts"; -export * from "./bookTracker.ts"; -export * from "./client.ts"; -export * from "./config.ts"; -export * from "./errors.ts"; -export * from "./errSerializer.ts"; -export * from "./gasTracker.ts"; -export * from "./healthcheck.ts"; -export * from "./inventoryManager.ts"; -export * from "./math.ts"; -export * from "./oracleTracker.ts"; -export * from "./orderExecutor.ts"; -export * from "./quoter.ts"; -export * from "./rational.ts"; -export * from "./registry.ts"; -export * from "./riskManager.ts"; -export * from "./wallet.ts"; -export { - computeMidQuote, - type EffectiveSpreadConfig, - type MidQuote, -} from "./pricing/effectiveSpread.ts"; -export { - computeReservationMidQuote, - type ReservationPriceConfig, -} from "./pricing/reservationPrice.ts"; -export { linearSizes } from "./sizing/linear.ts"; -export { geometricTaperSizes } from "./sizing/geometricTaper.ts"; -export { - calculateOrders, - resampleHourlyClose, - realizedVolatility, - type PricedOrder, - type TimedPrice, - type VolatilityResult, -} from "./helpers.ts"; diff --git a/market-maker/src/inventoryManager.ts b/market-maker/src/inventoryManager.ts deleted file mode 100644 index 27c151c..0000000 --- a/market-maker/src/inventoryManager.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type pino from "pino"; -import Fraction from "fraction.js"; -import type { InstrumentAdapter } from "./adapter.ts"; -import { bigAbs } from "./math.ts"; - -export interface InventoryManagerConfig { - /** Max absolute net position; used for skew normalisation. */ - maxPositionSize: bigint; -} - -/** - * Tracks the MM's position on a single instrument plus the venue's collateral snapshot. - * - * One inventory manager per instrument. Collateral is shared across all instruments on - * the same venue, so multi-instrument deployments would aggregate margin separately. - */ -export class InventoryManager { - netQuantity = 0n; - entryPrice = 0n; - - collateralBalance = 0n; - maintenanceMargin = 0n; - walletTokenBalance = 0n; - nativeBalance = 0n; - collateralTokenAddress: `0x${string}` | null = null; - - /** Margin available to back new exposure (collateralBalance − maintenanceMargin). */ - availableMargin = 0n; - /** Maintenance margin / collateral as a Fraction in [0, 1]. */ - utilization: Fraction = new Fraction(0n); - /** netQuantity / maxPositionSize as a Fraction in [-1, 1]. */ - inventorySkew: Fraction = new Fraction(0n); - - private readonly instrument: InstrumentAdapter; - private readonly cfg: InventoryManagerConfig; - private readonly logger: pino.Logger; - - constructor(instrument: InstrumentAdapter, cfg: InventoryManagerConfig, logger: pino.Logger) { - this.instrument = instrument; - this.cfg = cfg; - this.logger = logger.child({ component: "inventory", instrument: instrument.id }); - } - - async update(): Promise { - const [pos, collateral] = await Promise.all([ - this.instrument.getPosition(), - this.instrument.venue.getCollateral(), - ]); - - this.netQuantity = pos.netQuantity; - this.entryPrice = pos.entryPrice; - - this.collateralBalance = collateral.balance; - this.maintenanceMargin = collateral.maintenanceMargin; - this.walletTokenBalance = collateral.walletTokenBalance; - this.nativeBalance = collateral.nativeBalance; - this.collateralTokenAddress = collateral.collateralTokenAddress; - - this.availableMargin = - this.collateralBalance > this.maintenanceMargin - ? this.collateralBalance - this.maintenanceMargin - : 0n; - - this.utilization = - this.collateralBalance > 0n - ? new Fraction(this.maintenanceMargin, this.collateralBalance) - : new Fraction(0n); - - const maxPos = this.cfg.maxPositionSize; - if (maxPos > 0n) { - const raw = new Fraction(this.netQuantity, maxPos); - const one = new Fraction(1n); - const negOne = new Fraction(-1n); - this.inventorySkew = raw.compare(one) > 0 ? one : raw.compare(negOne) < 0 ? negOne : raw; - } else { - this.inventorySkew = new Fraction(0n); - } - - this.logger.debug( - { - net: this.netQuantity.toString(), - balance: this.collateralBalance.toString(), - skew: this.inventorySkew.valueOf(), - utilization: this.utilization.valueOf(), - }, - "inventory tick", - ); - } - - get hasPosition(): boolean { - return this.netQuantity !== 0n; - } - - get absPosition(): bigint { - return bigAbs(this.netQuantity); - } - - /** Utilization as integer percent in [0, 100]. */ - get utilizationPct(): number { - return Number(this.utilization.mul(new Fraction(100n)).round().valueOf()); - } -} diff --git a/market-maker/src/main.ts b/market-maker/src/main.ts deleted file mode 100644 index dee0b17..0000000 --- a/market-maker/src/main.ts +++ /dev/null @@ -1,305 +0,0 @@ -import pino from "pino"; -import { - BookTracker, - GasTracker, - HealthCheck, - InventoryManager, - OracleTracker, - OrderExecutor, - Quoter, - RiskManager, - WalletRegistry, - configBigint, - createAdapter, - createNetworkClients, - loadConfig, - serializeError, - toErrorInfo, -} from "./index.ts"; -import "./adapters/perps/index.ts"; -import "./adapters/futures/index.ts"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -async function main(): Promise { - const config = loadConfig(); - - const logger = pino({ - level: config.logLevel, - serializers: { err: serializeError }, - }); - - logger.info( - { - venue: config.venue.kind, - address: config.venue.address, - network: config.network.name, - dryRun: config.dryRun, - }, - "starting market maker", - ); - - const network = createNetworkClients(config.network.name, config.network.rpcUrl); - const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); - - const venue = await createAdapter(config.venue.kind, { - config, - wallets, - network, - logger, - }); - - const instruments = await venue.listInstruments(); - if (instruments.length === 0) { - throw new Error(`venue "${config.venue.kind}" returned no instruments`); - } - const instrument = instruments[0]; - if (instruments.length > 1) { - logger.warn( - { count: instruments.length, picking: instrument.id }, - "multi-instrument support is not yet wired in apps/maker; using first instrument only", - ); - } - - const mmAddress = venue.wallet.account.address; - logger.info({ wallet: venue.wallet.name, address: mmAddress }, "wallet ready"); - - const oracle = new OracleTracker(instrument, logger); - const gas = new GasTracker( - network.publicClient, - { - ethPriceFeedAddress: config.network.ethPriceFeed, - gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, - gasCapMultiplier: config.gas.gasCapMultiplier, - }, - logger, - ); - const inventory = new InventoryManager( - instrument, - { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, - logger, - ); - const risk = new RiskManager( - { - maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), - maxUtilizationPct: config.risk.maxUtilizationPct, - minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), - maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), - maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), - maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), - }, - inventory, - gas, - oracle, - logger, - ); - const book = new BookTracker( - instrument, - { - resyncIntervalMs: config.timing.resyncIntervalMs, - snapshotDepth: 200, - }, - logger, - ); - - const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); - - const quoter = new Quoter( - instrument, - { - pricing: config.pricing.strategy === "reservation-price" - ? { - strategy: "reservation-price" as const, - riskAversion: config.pricing.riskAversion ?? 0.1, - marginCallTimeSeconds: config.pricing.marginCallTimeSeconds ?? 3600, - minSpreadBps: config.pricing.minSpreadBps, - volatilityMultiplier: config.pricing.volatilityMultiplier, - gasPenaltyBps: config.risk.gasPenaltyBps, - } - : { - strategy: "effective-spread" as const, - minSpreadBps: config.pricing.minSpreadBps, - volatilityMultiplier: config.pricing.volatilityMultiplier, - inventorySkewGamma: config.pricing.inventorySkewGamma ?? 0, - gasPenaltyBps: config.risk.gasPenaltyBps, - }, - sizing: - config.sizing.strategy === "geometric-taper" - ? { - strategy: "geometric-taper", - baseQuantity, - numLevelsPerSide: config.sizing.numLevelsPerSide, - taperRatio: config.sizing.taperRatio ?? 0.5, - } - : { - strategy: "linear", - baseQuantity, - numLevelsPerSide: config.sizing.numLevelsPerSide, - }, - maxSkewTicks: config.pricing.maxSkewTicks, - }, - oracle, - gas, - inventory, - risk, - logger, - ); - const executor = new OrderExecutor( - instrument, - { - requoteCooldownMs: config.timing.requoteCooldownMs, - requoteThresholdTicks: config.timing.requoteThresholdTicks, - urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, - dryRun: config.dryRun, - }, - quoter, - book, - gas, - risk, - oracle, - logger, - ); - const health = new HealthCheck({ - port: config.health.port, - config, - oracle, - inventory, - book, - gas, - risk, - logger, - }); - - health.executorStats = executor.stats; - health.walletAddress = mmAddress; - - health.onStop = async () => { - logger.info("stop requested via API, cancelling orders"); - await executor.cancelAll(); - book.stop(); - }; - health.onStart = async () => { - logger.info("start requested via API, re-initializing"); - await book.start(); - await oracle.update(); - await gas.update(); - await inventory.update(); - }; - - await health.start(); - - const BASE_ERROR_DELAY_MS = 5_000; - const MAX_ERROR_DELAY_MS = 3 * 60_000; - - for (let attempt = 1; ; attempt++) { - try { - await quoter.initialize(); - await gas.calibrate(() => instrument.estimateCreateGas(mmAddress)); - await book.start(); - await oracle.update(); - await gas.update(); - await inventory.update(); - risk.initialize(); - health.status = "running"; - health.lastError = null; - break; - } catch (err) { - health.status = "init-error"; - health.lastError = toErrorInfo(err); - const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); - logger.warn({ err, attempt, retryInMs: delay }, "initialization failed, retrying"); - await sleep(delay); - } - } - - logger.info("initialization complete, entering main loop"); - - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down…"); - try { - await executor.cancelAll(); - } catch (err) { - logger.error({ err }, "failed to cancel orders during shutdown"); - } - book.stop(); - await health.stop(); - process.exit(0); - }; - process.on("SIGINT", () => void shutdown()); - process.on("SIGTERM", () => void shutdown()); - - let consecutiveErrors = 0; - while (!shuttingDown) { - if (health.paused) { - await sleep(config.timing.pollIntervalMs); - continue; - } - - try { - await oracle.update(); - await gas.update(); - await book.refresh(); - await inventory.update(); - - if (inventory.walletTokenBalance > 0n && config.nodeEnv === "production") { - try { - await venue.topUpCollateral(inventory.walletTokenBalance); - await inventory.update(); - } catch (err) { - health.status = "error"; - health.lastError = toErrorInfo(err); - logger.error({ err }, "failed to top up collateral"); - } - } - - logger.info( - { - oracle: oracle.currentPrice.toString(), - bid: book.bestBid.toString(), - ask: book.bestAsk.toString(), - pos: inventory.netQuantity.toString(), - collateralBalance: inventory.collateralBalance.toString(), - orders: book.ownOrders.size, - }, - "tick", - ); - - const ok = risk.check(); - if (!ok) { - health.status = "error"; - health.lastError = risk.haltReason; - consecutiveErrors++; - try { - await executor.cancelAll(); - } catch (err) { - health.lastError = toErrorInfo(err); - logger.error({ err }, "failed to cancel orders after risk halt"); - } - } else { - const desired = quoter.computeQuotes(); - await executor.reconcile(desired); - health.status = "running"; - health.lastError = null; - consecutiveErrors = 0; - } - } catch (err) { - consecutiveErrors++; - health.status = "error"; - health.lastError = toErrorInfo(err); - logger.error({ err }, "tick error"); - } - - health.tickCount++; - health.lastTickAt = Date.now(); - const delay = - consecutiveErrors > 0 - ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) - : config.timing.pollIntervalMs; - await sleep(delay); - } -} - -main(); diff --git a/market-maker/src/pricing/reservationPrice.ts b/market-maker/src/pricing/reservationPrice.ts deleted file mode 100644 index be26805..0000000 --- a/market-maker/src/pricing/reservationPrice.ts +++ /dev/null @@ -1,111 +0,0 @@ -import Fraction from "fraction.js"; -import { fromNumber, fromRatio, toBigint } from "../rational.ts"; -import { BPS_SCALE, QUANTITY_SCALE, roundDownToTick, roundUpToTick } from "../math.ts"; -import type { OracleTracker } from "../oracleTracker.ts"; -import type { GasTracker } from "../gasTracker.ts"; -import type { InventoryManager } from "../inventoryManager.ts"; -import type { InstrumentContext } from "../adapter.ts"; -import type { MidQuote } from "./effectiveSpread.ts"; - -export interface ReservationPriceConfig { - /** Avellaneda–Stoikov risk aversion γ. Positive → short inventory pushes mid up, - * long inventory pushes mid down. Tuned relative to the σ × T product in your - * deployment: if σ is per-poll (~3 s) and T is in seconds, γ should be chosen - * to produce a sensible tick-level shift at max inventory. */ - riskAversion: number; - /** Fallback remaining-time value (seconds) used when InstrumentContext has no - * deliveryDate (e.g. during testing or for non-delivery instruments). */ - marginCallTimeSeconds: number; - /** Floor half-spread in basis points. */ - minSpreadBps: number; - /** Widens spread by this multiple of σ (per-poll vol × 10 000 bps). */ - volatilityMultiplier: number; - /** Penalty added to spread when gas price spikes. */ - gasPenaltyBps: number; -} - -/** - * Avellaneda–Stoikov reservation-price mid-quote. - * - * r = S − q · γ · σ² · T - * - * S = oracle index price - * q = signed inventory in contracts (netQuantity / QUANTITY_SCALE) - * γ = risk aversion coefficient (ReservationPriceConfig.riskAversion) - * σ = realized volatility per poll step (OracleTracker.volatility, a Fraction) - * T = remaining time in seconds until delivery / margin-call - * - * All arithmetic on Fraction until the final tick-quantisation step. - * The spread around r is computed identically to EffectiveSpread - * (vol-widened, gas-floored), so the two strategies are drop-in-swappable - * inside Quoter. - */ -export function computeReservationMidQuote(opts: { - oracle: OracleTracker; - gas: GasTracker; - inventory: InventoryManager; - context: InstrumentContext; - cfg: ReservationPriceConfig; - tick: bigint; - nowMs?: number; -}): MidQuote { - const { oracle, gas, inventory, context, cfg, tick, nowMs = Date.now() } = opts; - const S = oracle.currentPrice; - - // ── Reservation price ──────────────────────────────────────────────────── - const sigma = oracle.volatility; // Fraction, per-poll - const sigma2 = sigma.mul(sigma); - const gamma = fromNumber(cfg.riskAversion); - - const remainingSeconds: Fraction = context.deliveryDate !== undefined - ? fromNumber(Math.max(0, context.deliveryDate - nowMs / 1000)) - : fromNumber(cfg.marginCallTimeSeconds); - - // q = netQuantity / QUANTITY_SCALE (signed, in "contracts") - const q = new Fraction(inventory.netQuantity, QUANTITY_SCALE); - - // r = S - q·γ·σ²·T - const adjustment = q.mul(gamma).mul(sigma2).mul(remainingSeconds); - const rFrac = fromRatio(S).sub(adjustment); - const rBigint = toBigint(rFrac, 1n, "nearest"); - const r = rBigint > tick ? rBigint : tick; // floor at 1 tick - - // ── Spread ─────────────────────────────────────────────────────────────── - const spreadBps = halfSpreadBps({ oracle, gas, cfg }).mul(new Fraction(2n)); - const halfBps = halfSpreadBps({ oracle, gas, cfg }); - const halfBpsBig = toBigint(halfBps, 1n, "nearest"); - - const bidRaw = (r * (BPS_SCALE - halfBpsBig)) / BPS_SCALE; - const askRaw = (r * (BPS_SCALE + halfBpsBig)) / BPS_SCALE; - - const bidMid = roundDownToTick(bidRaw > tick ? bidRaw : tick, tick); - const askMid = roundUpToTick(askRaw > tick ? askRaw : tick, tick); - - return { bidMid, askMid, spreadBps }; -} - -function halfSpreadBps(opts: { - oracle: OracleTracker; - gas: GasTracker; - cfg: ReservationPriceConfig; -}): Fraction { - const { oracle, gas, cfg } = opts; - - const minSpread = fromNumber(cfg.minSpreadBps / 2); // half-spread floor - - // vol component: σ * volatilityMultiplier * 10000 bps, halved for half-spread - const volBps = oracle.volatility - .mul(fromNumber(cfg.volatilityMultiplier)) - .mul(new Fraction(10_000n)) - .div(new Fraction(2n)); - - const base = volBps.compare(minSpread) > 0 ? volBps : minSpread; - - // gas spike penalty - const spike = gas.gasSpikePct; - const gasPenalty = spike.compare(new Fraction(0n)) > 0 - ? spike.div(new Fraction(100n)).mul(fromNumber(cfg.gasPenaltyBps / 2)) - : new Fraction(0n); - - return base.add(gasPenalty); -} diff --git a/market-maker/src/registry.ts b/market-maker/src/registry.ts deleted file mode 100644 index e69eed0..0000000 --- a/market-maker/src/registry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type pino from "pino"; -import { ConfigError } from "./errors.ts"; -import type { VenueAdapter, VenueKind } from "./adapter.ts"; -import type { MakerConfig } from "./config.ts"; -import type { WalletRegistry } from "./wallet.ts"; -import type { NetworkClients } from "./client.ts"; - -export interface AdapterFactoryContext { - config: MakerConfig; - wallets: WalletRegistry; - network: NetworkClients; - logger: pino.Logger; -} - -export type AdapterFactory = (ctx: AdapterFactoryContext) => Promise; - -const factories = new Map(); - -export function registerAdapter(kind: VenueKind, factory: AdapterFactory): void { - factories.set(kind, factory); -} - -export async function createAdapter( - kind: VenueKind, - ctx: AdapterFactoryContext, -): Promise { - const factory = factories.get(kind); - if (!factory) { - const known = [...factories.keys()].join(", ") || ""; - throw new ConfigError(`No adapter registered for kind "${kind}". Registered: ${known}`); - } - return factory(ctx); -} - -/** Test helper: clear the registry. */ -export function _clearRegistry(): void { - factories.clear(); -} diff --git a/market-maker/src/sizing/geometricTaper.ts b/market-maker/src/sizing/geometricTaper.ts deleted file mode 100644 index 06d80a1..0000000 --- a/market-maker/src/sizing/geometricTaper.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Fraction from "fraction.js"; -import { toBigint } from "../rational.ts"; - -/** - * Geometric taper sizing: each successive level is `ratio` of the previous one. - * The total inventory across all levels equals `totalQuantity`. - * - * q_k = totalQuantity * ratio^k * (1 − ratio) / (1 − ratio^N) - * - * For ratio=0.5, sizes are { Q/2, Q/4, Q/8, ... }. For ratio→1, sizes flatten. - * - * Throws if ratio ∉ (0, 1) or numLevels < 1. Uses Fraction for exactness then - * floors to bigint per level. - */ -export function geometricTaperSizes(totalQuantity: bigint, ratio: number, numLevels: number): bigint[] { - if (numLevels < 1) throw new Error("numLevels must be >= 1"); - if (!(ratio > 0 && ratio < 1)) throw new Error("ratio must be in (0, 1)"); - const r = new Fraction(Math.round(ratio * 1_000_000), 1_000_000); - const one = new Fraction(1n); - // ratio^k - const powers: Fraction[] = []; - let p = one; - for (let k = 0; k < numLevels; k++) { - powers.push(p); - p = p.mul(r); - } - let denom = new Fraction(0n); - for (const x of powers) denom = denom.add(x); - const totalQ = new Fraction(totalQuantity); - const out: bigint[] = []; - for (const pk of powers) { - const qFrac = totalQ.mul(pk).div(denom); - out.push(toBigint(qFrac, 1n, "floor")); - } - return out; -} diff --git a/market-maker/tests/apps/futures/config.test.ts b/market-maker/tests/apps/futures/config.test.ts new file mode 100644 index 0000000..19fd8ea --- /dev/null +++ b/market-maker/tests/apps/futures/config.test.ts @@ -0,0 +1,135 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadFuturesConfig } from "../../../src/apps/futures/config.ts"; + +function writeTmp(dir: string, name: string, content: string): string { + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +const VALID_YAML = ` +wallets: + default: + privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +network: + name: arbitrum + rpcUrl: "https://arb1.arbitrum.io/rpc" +venue: + kind: futures + wallet: default + address: "0x1234567890123456789012345678901234567890" +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 +sizing: + strategy: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6 +risk: + maxPositionSize: "50000000" + maxUtilizationPct: 80 + minCollateralBalance: "10000000" + maxDailyLossUsd: "500000000" +gas: + gasCapMultiplier: 2.0 +timing: {} +collateral: {} +health: + port: 8080 +`; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mm-fut-cfg-")); +}); + +afterEach(() => { + try { unlinkSync(join(tmpDir, "test.yml")); } catch { /* ignore */ } +}); + +describe("loadFuturesConfig", () => { + it("parses a valid YAML file", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadFuturesConfig({ path }); + assert.strictEqual(cfg.venue.kind, "futures"); + assert.strictEqual(cfg.pricing.strategy, "reservation-price"); + assert.strictEqual(cfg.sizing.strategy, "geometric-taper"); + assert.strictEqual(cfg.sizing.taperRatio, 0.6); + }); + + it("rejects effective-spread strategy on futures", () => { + const yaml = VALID_YAML.replace( + `pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSeconds: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0`, + `pricing: + strategy: effective-spread + minSpreadBps: 10 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20`, + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); + + it("rejects linear sizing on futures", () => { + const yaml = VALID_YAML.replace( + `sizing: + strategy: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6`, + `sizing: + strategy: linear + baseQuantity: "500000000" + numLevelsPerSide: 4`, + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); + + it("rejects taperRatio outside (0, 1)", () => { + const yaml = VALID_YAML.replace("taperRatio: 0.6", "taperRatio: 1.0"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); + + it("requires riskAversion and marginCallTimeSeconds", () => { + const yaml = VALID_YAML.replace(" riskAversion: 0.001\n marginCallTimeSeconds: 3600\n", ""); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); + + it("throws when venue.wallet is not declared in wallets map", () => { + const yaml = VALID_YAML.replace("wallet: default", "wallet: undeclaredWallet"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /undeclaredWallet/); + }); + + it("rejects unknown top-level keys", () => { + const yaml = `${VALID_YAML}\nbogus: 1\n`; + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); + + it("rejects unknown nested keys", () => { + const yaml = VALID_YAML.replace(" port: 8080", " port: 8080\n bogusHealthField: true"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); + }); +}); diff --git a/market-maker/tests/apps/futures/main.smoke.test.ts b/market-maker/tests/apps/futures/main.smoke.test.ts new file mode 100644 index 0000000..8e55ce5 --- /dev/null +++ b/market-maker/tests/apps/futures/main.smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { loadFuturesConfig } from "../../../src/apps/futures/config.ts"; + +/** + * Smoke test for the bundled futures configs. Catches drift between the + * schema and the per-env YAMLs shipped under configs/{dev,stg,prd}/futures.yml. + */ +describe("futures app config smoke", () => { + const envs = ["local", "dev", "stg", "prd"] as const; + + for (const e of envs) { + it(`loads configs/futures.${e}.yml with stub env`, () => { + const path = resolve(import.meta.dirname, `../../../configs/futures.${e}.yml`); + const env: NodeJS.ProcessEnv = { + PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ETH_NODE_ADDRESS: "https://example.com/rpc", + FUTURES_ADDRESS: "0x1234567890123456789012345678901234567890", + NETWORK: "arbitrum", + }; + const cfg = loadFuturesConfig({ path, env }); + assert.equal(cfg.venue.kind, "futures"); + assert.equal(cfg.pricing.strategy, "reservation-price"); + assert.equal(cfg.sizing.strategy, "geometric-taper"); + assert.ok(cfg.sizing.taperRatio > 0 && cfg.sizing.taperRatio < 1); + }); + } +}); diff --git a/market-maker/tests/config.test.ts b/market-maker/tests/apps/perps/config.test.ts similarity index 53% rename from market-maker/tests/config.test.ts rename to market-maker/tests/apps/perps/config.test.ts index 3bff9eb..95b1646 100644 --- a/market-maker/tests/config.test.ts +++ b/market-maker/tests/apps/perps/config.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadConfig, type MakerConfig } from "../src/config.ts"; +import { loadPerpsConfig } from "../../../src/apps/perps/config.ts"; function writeTmp(dir: string, name: string, content: string): string { const path = join(dir, name); @@ -41,6 +41,7 @@ gas: gasCapMultiplier: 2.0 timing: pollIntervalMs: 3000 +collateral: {} health: port: 8080 `; @@ -48,96 +49,61 @@ health: let tmpDir: string; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "mm-cfg-")); + tmpDir = mkdtempSync(join(tmpdir(), "mm-perps-cfg-")); }); afterEach(() => { try { unlinkSync(join(tmpDir, "test.yml")); } catch { /* ignore */ } }); -describe("loadConfig", () => { +describe("loadPerpsConfig", () => { it("parses a valid YAML file", () => { const path = writeTmp(tmpDir, "test.yml", VALID_YAML); - const cfg = loadConfig({ path }); + const cfg = loadPerpsConfig({ path }); assert.strictEqual(cfg.venue.kind, "perps"); assert.strictEqual(cfg.network.name, "arbitrum"); - assert.strictEqual(cfg.pricing.minSpreadBps, 10); - assert.strictEqual(cfg.sizing.baseQuantity, "1000000"); + assert.strictEqual(cfg.pricing.strategy, "effective-spread"); + assert.strictEqual(cfg.sizing.strategy, "linear"); }); - it("applies defaults for optional fields", () => { - // Remove the whole timing block to trigger defaults - const yaml = VALID_YAML.replace( - /^timing:\n pollIntervalMs: 3000\n/m, - "timing: {}\n", - ); + it("rejects reservation-price strategy on perps", () => { + const yaml = VALID_YAML + .replace("strategy: effective-spread", "strategy: reservation-price") + .replace(" inventorySkewGamma: 0.5\n", " riskAversion: 0.2\n marginCallTimeSeconds: 3600\n"); const path = writeTmp(tmpDir, "test.yml", yaml); - const cfg = loadConfig({ path }); - assert.strictEqual(cfg.timing.pollIntervalMs, 3000); // default + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); + }); + + it("rejects geometric-taper sizing on perps", () => { + const yaml = VALID_YAML + .replace("strategy: linear", "strategy: geometric-taper") + .replace(" numLevelsPerSide: 5\n", " numLevelsPerSide: 4\n taperRatio: 0.6\n"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); it("expands ${VAR} tokens from env", () => { - const yaml = ` -wallets: - default: - privateKey: \${TEST_PRIVATE_KEY} -network: - name: arbitrum - rpcUrl: \${TEST_RPC_URL} -venue: - kind: perps - wallet: default - address: "0x1234567890123456789012345678901234567890" -pricing: - strategy: effective-spread - minSpreadBps: 5 - volatilityMultiplier: 1.0 - inventorySkewGamma: 0.3 - maxSkewTicks: 10 -sizing: - strategy: linear - baseQuantity: "500000" - numLevelsPerSide: 3 -risk: - maxPositionSize: "10000000" - minCollateralBalance: "5000000" - maxDailyLossUsd: "100000000" -gas: - gasCapMultiplier: 1.5 -timing: {} -health: {} -`; + const yaml = VALID_YAML + .replace('"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', "${TEST_PRIVATE_KEY}") + .replace('"https://arb1.arbitrum.io/rpc"', "${TEST_RPC_URL}"); const path = writeTmp(tmpDir, "test.yml", yaml); const env: NodeJS.ProcessEnv = { TEST_PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", TEST_RPC_URL: "https://example.com/rpc", }; - const cfg = loadConfig({ path, env }); - assert.strictEqual(cfg.wallets["default"].privateKey, env.TEST_PRIVATE_KEY); + const cfg = loadPerpsConfig({ path, env }); assert.strictEqual(cfg.network.rpcUrl, "https://example.com/rpc"); }); - it("throws for missing env variable", () => { - const yaml = VALID_YAML.replace( - '"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', - "${MISSING_VAR}", - ); - const path = writeTmp(tmpDir, "test.yml", yaml); - assert.throws( - () => loadConfig({ path, env: {} }), - /MISSING_VAR/, - ); - }); - it("supports ${VAR:-default} fallback syntax", () => { const yaml = VALID_YAML.replace( '"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', '${ABSENT_KEY:-0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890}', ); const path = writeTmp(tmpDir, "test.yml", yaml); - const cfg = loadConfig({ path, env: {} }); + const cfg = loadPerpsConfig({ path, env: {} }); assert.strictEqual( - cfg.wallets["default"].privateKey, + cfg.wallets.default.privateKey, "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", ); }); @@ -145,13 +111,7 @@ health: {} it("throws when venue.wallet is not declared in wallets map", () => { const yaml = VALID_YAML.replace("wallet: default", "wallet: undeclaredWallet"); const path = writeTmp(tmpDir, "test.yml", yaml); - assert.throws(() => loadConfig({ path }), /undeclaredWallet/); - }); - - it("throws on invalid venue.kind", () => { - const yaml = VALID_YAML.replace("kind: perps", "kind: invalidkind"); - const path = writeTmp(tmpDir, "test.yml", yaml); - assert.throws(() => loadConfig({ path }), /Config validation failed/); + assert.throws(() => loadPerpsConfig({ path }), /undeclaredWallet/); }); it("throws on invalid address format", () => { @@ -160,39 +120,18 @@ health: {} '"not-an-address"', ); const path = writeTmp(tmpDir, "test.yml", yaml); - assert.throws(() => loadConfig({ path }), /Config validation failed/); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); - it("throws when config file does not exist", () => { - assert.throws( - () => loadConfig({ path: "/nonexistent/path/config.yml" }), - /Failed to read config/, - ); - }); - - it("throws when no path provided and no MAKER_CONFIG env", () => { - assert.throws( - () => loadConfig({ env: {} }), // no MAKER_CONFIG in env, no argv flag - /No config path/, - ); - }); - - it("accepts reservation-price strategy with riskAversion", () => { - const yaml = VALID_YAML - .replace("strategy: effective-spread", "strategy: reservation-price") - .replace(" inventorySkewGamma: 0.5\n", " riskAversion: 0.2\n marginCallTimeSeconds: 3600\n"); + it("rejects unknown top-level keys", () => { + const yaml = `${VALID_YAML}\nbogus: 1\n`; const path = writeTmp(tmpDir, "test.yml", yaml); - const cfg = loadConfig({ path }); - assert.strictEqual(cfg.pricing.strategy, "reservation-price"); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); - it("parses futures.yml-style config with geometric-taper sizing", () => { - const yaml = VALID_YAML - .replace("strategy: linear", "strategy: geometric-taper") - .replace(" numLevelsPerSide: 5\n", " numLevelsPerSide: 4\n taperRatio: 0.6\n"); + it("rejects unknown nested keys", () => { + const yaml = VALID_YAML.replace(" gasCapMultiplier: 2.0", " gasCapMultiplier: 2.0\n bogusGasField: 1"); const path = writeTmp(tmpDir, "test.yml", yaml); - const cfg: MakerConfig = loadConfig({ path }); - assert.strictEqual(cfg.sizing.strategy, "geometric-taper"); - assert.strictEqual(cfg.sizing.taperRatio, 0.6); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); }); diff --git a/market-maker/tests/apps/perps/main.smoke.test.ts b/market-maker/tests/apps/perps/main.smoke.test.ts new file mode 100644 index 0000000..ad135ba --- /dev/null +++ b/market-maker/tests/apps/perps/main.smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { loadPerpsConfig } from "../../../src/apps/perps/config.ts"; + +/** + * Smoke test for the bundled perps configs. Catches drift between the + * schema and the per-env YAMLs shipped under configs/{dev,stg,prd}/perps.yml. + */ +describe("perps app config smoke", () => { + const envs = ["local", "dev", "stg", "prd"] as const; + + for (const e of envs) { + it(`loads configs/perps.${e}.yml with stub env`, () => { + const path = resolve(import.meta.dirname, `../../../configs/perps.${e}.yml`); + const env: NodeJS.ProcessEnv = { + PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ETH_NODE_ADDRESS: "https://example.com/rpc", + PERPS_ADDRESS: "0x1234567890123456789012345678901234567890", + NETWORK: "arbitrum", + }; + const cfg = loadPerpsConfig({ path, env }); + assert.equal(cfg.venue.kind, "perps"); + assert.equal(cfg.pricing.strategy, "effective-spread"); + assert.equal(cfg.sizing.strategy, "linear"); + assert.ok(cfg.timing.levelSpacingTicks >= 1); + }); + } +}); diff --git a/market-maker/tests/errSerializer.test.ts b/market-maker/tests/core/errSerializer.test.ts similarity index 96% rename from market-maker/tests/errSerializer.test.ts rename to market-maker/tests/core/errSerializer.test.ts index 0593e5f..069e8f1 100644 --- a/market-maker/tests/errSerializer.test.ts +++ b/market-maker/tests/core/errSerializer.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { serializeError, toErrorInfo } from "../src/errSerializer.ts"; +import { serializeError, toErrorInfo } from "../../src/core/errSerializer.ts"; describe("serializeError", () => { it("preserves message and stack of a simple Error", () => { diff --git a/market-maker/tests/gasTracker.test.ts b/market-maker/tests/core/gasTracker.test.ts similarity index 98% rename from market-maker/tests/gasTracker.test.ts rename to market-maker/tests/core/gasTracker.test.ts index 1d27747..a8736c3 100644 --- a/market-maker/tests/gasTracker.test.ts +++ b/market-maker/tests/core/gasTracker.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { PublicClient } from "viem"; -import { GasTracker, type GasTrackerConfig } from "../src/gasTracker.ts"; +import { GasTracker, type GasTrackerConfig } from "../../src/core/gasTracker.ts"; const noop = () => {}; function makeLogger(): never { diff --git a/market-maker/tests/helpers.test.ts b/market-maker/tests/core/helpers.test.ts similarity index 99% rename from market-maker/tests/helpers.test.ts rename to market-maker/tests/core/helpers.test.ts index 911f5ae..10dcc60 100644 --- a/market-maker/tests/helpers.test.ts +++ b/market-maker/tests/core/helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { calculateOrders, resampleHourlyClose, type PricedOrder } from "../src/helpers.ts"; +import { calculateOrders, resampleHourlyClose, type PricedOrder } from "../../src/core/helpers.ts"; const HOUR_MS = 60 * 60 * 1000; diff --git a/market-maker/tests/math.test.ts b/market-maker/tests/core/math.test.ts similarity index 99% rename from market-maker/tests/math.test.ts rename to market-maker/tests/core/math.test.ts index 24be33e..76653ab 100644 --- a/market-maker/tests/math.test.ts +++ b/market-maker/tests/core/math.test.ts @@ -10,7 +10,7 @@ import { roundDownToTick, roundToTick, roundUpToTick, -} from "../src/math.ts"; +} from "../../src/core/math.ts"; describe("rounding to tick (bigint)", () => { it("roundDownToTick aligned/unaligned", () => { diff --git a/market-maker/tests/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts similarity index 81% rename from market-maker/tests/oracleTracker.test.ts rename to market-maker/tests/core/oracleTracker.test.ts index abad051..113306a 100644 --- a/market-maker/tests/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { OracleTracker } from "../src/oracleTracker.ts"; -import type { InstrumentAdapter } from "../src/adapter.ts"; +import { OracleTracker } from "../../src/core/oracleTracker.ts"; +import type { InstrumentAdapter } from "../../src/core/adapter.ts"; const noop = () => {}; function makeLogger(): never { @@ -13,14 +13,22 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { return { id: "test", venue: {} as InstrumentAdapter["venue"], + book: { + matchingMode: "limit" as const, + tick: async () => 1n, + snapshot: async () => ({ bids: [], asks: [] }), + }, + ownOrders: { + list: async () => [], + subscribe: () => () => {}, + bootstrap: async () => {}, + }, getIndexPrice: async () => prices[Math.min(i++, prices.length - 1)], - getMinTick: async () => 1n, - getOwnOrders: async () => [], getPosition: async () => ({ netQuantity: 0n, entryPrice: 0n }), getContext: async () => ({}), - getOrderBookSnapshot: async () => ({ bids: [], asks: [] }), - buildCancelCalldata: () => "0x", - buildCreateCalldata: () => "0x", + encodeCreate: () => "0x", + encodeCancel: () => "0x", + estimateOrderMargin: () => 0n, estimateCreateGas: async () => 0n, }; } diff --git a/market-maker/tests/reservationPrice.test.ts b/market-maker/tests/core/pricing/reservationPrice.test.ts similarity index 92% rename from market-maker/tests/reservationPrice.test.ts rename to market-maker/tests/core/pricing/reservationPrice.test.ts index e2584e2..f8b01cc 100644 --- a/market-maker/tests/reservationPrice.test.ts +++ b/market-maker/tests/core/pricing/reservationPrice.test.ts @@ -1,12 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import Fraction from "fraction.js"; -import { computeReservationMidQuote } from "../src/pricing/reservationPrice.ts"; -import type { ReservationPriceConfig } from "../src/pricing/reservationPrice.ts"; -import type { OracleTracker } from "../src/oracleTracker.ts"; -import type { GasTracker } from "../src/gasTracker.ts"; -import type { InventoryManager } from "../src/inventoryManager.ts"; -import type { InstrumentContext } from "../src/adapter.ts"; +import { computeReservationMidQuote } from "../../../src/core/pricing/reservationPrice.ts"; +import type { ReservationPriceConfig } from "../../../src/core/pricing/reservationPrice.ts"; +import type { OracleTracker } from "../../../src/core/oracleTracker.ts"; +import type { GasTracker } from "../../../src/core/gasTracker.ts"; +import type { InventoryManager } from "../../../src/core/inventoryManager.ts"; +import type { InstrumentContext } from "../../../src/core/adapter.ts"; const TICK = 1_000n; // $0.001 in 6-decimal USDC diff --git a/market-maker/tests/realizedVolatility.test.ts b/market-maker/tests/core/realizedVolatility.test.ts similarity index 98% rename from market-maker/tests/realizedVolatility.test.ts rename to market-maker/tests/core/realizedVolatility.test.ts index aa7486e..c66a7fe 100644 --- a/market-maker/tests/realizedVolatility.test.ts +++ b/market-maker/tests/core/realizedVolatility.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { realizedVolatility } from "../src/helpers.ts"; +import { realizedVolatility } from "../../src/core/helpers.ts"; describe("realizedVolatility", () => { it("returns 0 for empty array", () => diff --git a/market-maker/tests/riskManager.test.ts b/market-maker/tests/core/riskManager.test.ts similarity index 58% rename from market-maker/tests/riskManager.test.ts rename to market-maker/tests/core/riskManager.test.ts index a0aa728..94a79d8 100644 --- a/market-maker/tests/riskManager.test.ts +++ b/market-maker/tests/core/riskManager.test.ts @@ -1,10 +1,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import Fraction from "fraction.js"; -import { RiskManager, type RiskManagerConfig } from "../src/riskManager.ts"; -import type { InventoryManager } from "../src/inventoryManager.ts"; -import type { GasTracker } from "../src/gasTracker.ts"; -import type { OracleTracker } from "../src/oracleTracker.ts"; +import { RiskManager, type RiskManagerConfig } from "../../src/core/riskManager.ts"; +import type { CollateralTracker } from "../../src/core/collateralTracker.ts"; +import type { InventoryManager } from "../../src/core/inventoryManager.ts"; +import type { GasTracker } from "../../src/core/gasTracker.ts"; +import type { OracleTracker } from "../../src/core/oracleTracker.ts"; const noop = () => {}; function makeLogger(): never { @@ -26,40 +27,75 @@ function makeConfig(overrides: Partial = {}): RiskManagerConf function makeInventory(overrides: Partial = {}): InventoryManager { return { netQuantity: 0n, - collateralBalance: 1_000_000_000n, - maintenanceMargin: 100_000_000n, inventorySkew: new Fraction(0n), - availableMargin: 900_000_000n, - utilizationPct: 10, ...overrides, } as InventoryManager; } +function makeCollateral(overrides: Partial = {}): CollateralTracker { + return { + vaultBalance: 1_000_000_000n, + portfolioIM: 100_000_000n, + portfolioMM: 50_000_000n, + utilizationPct: 10, + canPlace: async () => true, + ...overrides, + } as CollateralTracker; +} + const dummyGas = {} as GasTracker; const dummyOracle = { currentPrice: 100_000_000n } as OracleTracker; describe("RiskManager", () => { it("allows quoting when healthy", () => { - const r = new RiskManager(makeConfig(), makeInventory(), dummyGas, dummyOracle, makeLogger()); + const r = new RiskManager( + makeConfig(), + makeInventory(), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); r.initialize(); assert.equal(r.check(), true); assert.equal(r.halted, false); }); it("halts when collateral drops below minimum", () => { - const inv = makeInventory({ collateralBalance: 50_000_000n }); - const r = new RiskManager(makeConfig({ minCollateralBalance: 100_000_000n }), inv, dummyGas, dummyOracle, makeLogger()); + const r = new RiskManager( + makeConfig({ minCollateralBalance: 100_000_000n }), + makeInventory(), + makeCollateral({ vaultBalance: 50_000_000n }), + dummyGas, + dummyOracle, + makeLogger(), + ); r.initialize(); assert.equal(r.check(), false); assert.equal(r.halted, true); assert.equal(r.haltReason?.message, "collateral below minimum"); }); + it("halts when portfolio MM is breached", () => { + const r = new RiskManager( + makeConfig({ minCollateralBalance: 0n }), + makeInventory(), + makeCollateral({ vaultBalance: 100_000_000n, portfolioMM: 200_000_000n }), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + assert.equal(r.check(), false); + assert.equal(r.haltReason?.message, "portfolio MM breached"); + }); + it("halts on daily loss exceeding limit", () => { - const inv = makeInventory({ collateralBalance: 400_000_000n }); + const collateral = makeCollateral({ vaultBalance: 400_000_000n }); const r = new RiskManager( makeConfig({ maxDailyLossUsd: 500_000_000n, minCollateralBalance: 0n }), - inv, + makeInventory(), + collateral, dummyGas, dummyOracle, makeLogger(), @@ -71,7 +107,14 @@ describe("RiskManager", () => { }); it("records gas costs into cumulative", () => { - const r = new RiskManager(makeConfig(), makeInventory(), dummyGas, dummyOracle, makeLogger()); + const r = new RiskManager( + makeConfig(), + makeInventory(), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); r.initialize(); r.recordGasCost(10_000_000n); r.recordGasCost(20_000_000n); @@ -79,14 +122,22 @@ describe("RiskManager", () => { }); it("allowedSides: both when neutral and within caps", () => { - const r = new RiskManager(makeConfig(), makeInventory({ netQuantity: 0n }), dummyGas, dummyOracle, makeLogger()); + const r = new RiskManager( + makeConfig(), + makeInventory({ netQuantity: 0n }), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); assert.deepEqual(r.allowedSides(), { quoteBid: true, quoteAsk: true }); }); it("allowedSides: blocks bid at max long with high utilization", () => { const r = new RiskManager( makeConfig({ maxPositionSize: 100_000_000n, maxUtilizationPct: 80 }), - makeInventory({ netQuantity: 100_000_000n, utilizationPct: 90 }), + makeInventory({ netQuantity: 100_000_000n }), + makeCollateral({ utilizationPct: 90 }), dummyGas, dummyOracle, makeLogger(), @@ -97,7 +148,8 @@ describe("RiskManager", () => { it("allowedSides: blocks ask at max short with high utilization", () => { const r = new RiskManager( makeConfig({ maxPositionSize: 100_000_000n, maxUtilizationPct: 80 }), - makeInventory({ netQuantity: -100_000_000n, utilizationPct: 90 }), + makeInventory({ netQuantity: -100_000_000n }), + makeCollateral({ utilizationPct: 90 }), dummyGas, dummyOracle, makeLogger(), @@ -108,7 +160,8 @@ describe("RiskManager", () => { it("allowedSides: blocks both when utilization high and position zero", () => { const r = new RiskManager( makeConfig({ maxUtilizationPct: 80 }), - makeInventory({ netQuantity: 0n, utilizationPct: 90 }), + makeInventory({ netQuantity: 0n }), + makeCollateral({ utilizationPct: 90 }), dummyGas, dummyOracle, makeLogger(), @@ -120,6 +173,7 @@ describe("RiskManager", () => { const r = new RiskManager( makeConfig({ maxGasBudgetPerHourUsd: 10_000_000n }), makeInventory(), + makeCollateral(), dummyGas, dummyOracle, makeLogger(), @@ -135,6 +189,7 @@ describe("RiskManager", () => { const r = new RiskManager( makeConfig({ maxGasBudgetPerHourUsd: 1_000_000_000n, maxGasBudgetPerDayUsd: 10_000_000n }), makeInventory(), + makeCollateral(), dummyGas, dummyOracle, makeLogger(), @@ -146,25 +201,11 @@ describe("RiskManager", () => { assert.equal(r.throttleReason, "gas_daily"); }); - it("includes gas in daily PnL calculation", () => { - const inv = makeInventory({ collateralBalance: 900_000_000n }); - const r = new RiskManager( - makeConfig({ maxDailyLossUsd: 200_000_000n, minCollateralBalance: 0n }), - inv, - dummyGas, - dummyOracle, - makeLogger(), - ); - r.initialize(); - r.recordGasCost(250_000_000n); - assert.equal(r.check(), false); - assert.equal(r.haltReason?.message, "daily loss limit breached"); - }); - it("resets PnL counters on day rollover", () => { const r = new RiskManager( makeConfig({ minCollateralBalance: 0n, maxDailyLossUsd: 1_000_000_000n }), - makeInventory({ collateralBalance: 1_000_000_000n }), + makeInventory(), + makeCollateral(), dummyGas, dummyOracle, makeLogger(), @@ -178,4 +219,45 @@ describe("RiskManager", () => { r.check(); assert.equal(r.cumulativeGasCostUsd, 0n); }); + + it("canPlaceOrders returns true on empty input", async () => { + const r = new RiskManager( + makeConfig(), + makeInventory(), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); + const dummyInstrument = { estimateOrderMargin: () => 0n } as never; + assert.equal(await r.canPlaceOrders([], dummyInstrument), true); + }); + + it("canPlaceOrders consults engine when total IM > 0", async () => { + const calls: bigint[] = []; + const collateral = makeCollateral({ + canPlace: async (im: bigint) => { + calls.push(im); + return im < 1_000n; + }, + }); + const r = new RiskManager( + makeConfig(), + makeInventory(), + collateral, + dummyGas, + dummyOracle, + makeLogger(), + ); + const dummyInstrument = { estimateOrderMargin: () => 400n } as never; + const allowed = await r.canPlaceOrders( + [ + { side: "buy", price: 1n, size: 1n }, + { side: "sell", price: 2n, size: 1n }, + ], + dummyInstrument, + ); + assert.equal(allowed, true); + assert.deepEqual(calls, [800n]); + }); }); diff --git a/market-maker/tests/geometricTaper.test.ts b/market-maker/tests/core/sizing/geometricTaper.test.ts similarity index 97% rename from market-maker/tests/geometricTaper.test.ts rename to market-maker/tests/core/sizing/geometricTaper.test.ts index 823fbf2..6ab6607 100644 --- a/market-maker/tests/geometricTaper.test.ts +++ b/market-maker/tests/core/sizing/geometricTaper.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { geometricTaperSizes } from "../src/sizing/geometricTaper.ts"; +import { geometricTaperSizes } from "../../../src/core/sizing/geometricTaper.ts"; /** * geometricTaperSizes(totalQuantity, ratio, numLevels) distributes totalQuantity From 85f6beac02b9c47fb4bd29f652083b7fce078ec9 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 18:36:52 +0200 Subject: [PATCH 006/180] refactor: update market maker configuration and schema to use Alchemy API key - Replaced `ETH_NODE_ADDRESS` with `ALCHEMY_API_KEY` in various configuration files for futures and perps environments. - Updated README and schema files to reflect changes in required environment variables. - Adjusted pricing and risk parameters across multiple configuration files for consistency and clarity. - Enhanced event handling in the futures and perps adapters to improve code readability and maintainability. --- market-maker/README.md | 19 +- market-maker/configs/futures.dev.yml | 42 +- market-maker/configs/futures.local.yml | 35 +- market-maker/configs/futures.prd.yml | 29 +- market-maker/configs/futures.stg.yml | 29 +- market-maker/configs/perps.dev.yml | 44 +- market-maker/configs/perps.local.yml | 27 +- market-maker/configs/perps.prd.yml | 27 +- market-maker/configs/perps.stg.yml | 27 +- market-maker/infra/README.md | 6 +- market-maker/infra/ecs-task.tf | 26 +- market-maker/schemas/futures.json | 426 ++++++++++++++++-- market-maker/schemas/perps.json | 418 +++++++++++++++-- market-maker/src/adapters/futures/events.ts | 11 +- .../src/adapters/futures/instrument.ts | 38 +- market-maker/src/adapters/futures/venue.ts | 33 +- market-maker/src/adapters/perps/events.ts | 11 +- market-maker/src/adapters/perps/instrument.ts | 21 +- market-maker/src/adapters/perps/venue.ts | 44 +- market-maker/src/apps/futures/config.ts | 139 ++++-- market-maker/src/apps/futures/main.ts | 22 +- market-maker/src/apps/perps/config.ts | 119 ++++- market-maker/src/apps/perps/main.ts | 20 +- market-maker/src/core/collateralTracker.ts | 36 +- market-maker/src/core/config/base.ts | 290 ++++++++++-- market-maker/src/core/config/units.ts | 72 +++ market-maker/src/core/rawOracle.ts | 93 ++++ market-maker/src/core/runner.ts | 21 +- market-maker/src/core/vaultDeposit.ts | 31 +- market-maker/tests-pending/config.test.ts | 2 +- .../market-maker.process.test.ts | 2 +- .../tests/apps/futures/config.test.ts | 14 +- .../tests/apps/futures/main.smoke.test.ts | 3 +- market-maker/tests/apps/perps/config.test.ts | 8 +- .../tests/apps/perps/main.smoke.test.ts | 3 +- .../tests/core/collateralTracker.test.ts | 128 ++++++ market-maker/tests/core/config/units.test.ts | 62 +++ 37 files changed, 1982 insertions(+), 396 deletions(-) create mode 100644 market-maker/src/core/config/units.ts create mode 100644 market-maker/src/core/rawOracle.ts create mode 100644 market-maker/tests/core/collateralTracker.test.ts create mode 100644 market-maker/tests/core/config/units.test.ts diff --git a/market-maker/README.md b/market-maker/README.md index a8f9b71..5e38c6e 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -113,13 +113,13 @@ node src/apps/perps/main.ts --config /tmp/my-perps.yml ### Required -| Variable | Description | -|---|---| -| `NETWORK` | Chain identifier: `arbitrum`, `arbitrum-sepolia`, or `hardhat` | -| `ETH_NODE_ADDRESS` | RPC endpoint (HTTP or WebSocket) | -| `PERPS_ADDRESS` | Deployed HashPowerPerpsDEX proxy contract address (perps app) | -| `FUTURES_ADDRESS` | Deployed Futures proxy contract address (futures app) | -| `PRIVATE_KEY` | Hex-encoded private key for the MM wallet | +| Variable | Used by | Description | +|---|---|---| +| `PRIVATE_KEY` | all envs | Hex-encoded private key for the MM wallet | +| `ALCHEMY_API_KEY` | dev/stg/prd | Alchemy API key. The bundled configs compose the RPC URL (`https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}` for dev, `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}` for stg/prd). | +| `PERPS_ADDRESS` | perps app | Deployed HashPowerPerpsDEX proxy contract address | +| `FUTURES_ADDRESS` | futures app | Deployed Futures proxy contract address | +| `ETH_PRICE_FEED_ADDRESS` | optional | Chainlink ETH/USD aggregator. Required to surface gas cost in USD; leave unset for local hardhat. | ### Quoting @@ -218,11 +218,10 @@ pnpm prd:futures ### Example `.env` ```env -NETWORK=arbitrum-sepolia -ETH_NODE_ADDRESS=https://sepolia-rollup.arbitrum.io/rpc +PRIVATE_KEY=0x... +ALCHEMY_API_KEY=... PERPS_ADDRESS=0x... FUTURES_ADDRESS=0x... -PRIVATE_KEY=0x... MAKER_DRY_RUN=false MAKER_HEALTH_PORT=3001 ``` diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index b926b55..552b2e0 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -2,14 +2,17 @@ # Titan Market Maker - Futures - DEV (base-sepolia). # # PRIVATE_KEY - hex private key of the dev market-making wallet -# ETH_NODE_ADDRESS - base-sepolia JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # FUTURES_ADDRESS - Futures address on base-sepolia # ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia nodeEnv: development commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-false} +dryRun: false +# Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we +# don't burn gas on cancel-then-reopen across every restart. +cancelOrdersOnShutdown: false wallets: primary: @@ -17,7 +20,7 @@ wallets: network: name: base-sepolia - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -28,24 +31,30 @@ venue: pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 - minSpreadBps: 15 - volatilityMultiplier: 2.5 + marginCallTimeSec: 3600 + # The MM reads the unrounded oracle answer (FuturesVenue.getRawMarketPrice), + # so the reservation price `r` lands between ticks and `bid = floor(r/tick)`, + # `ask = ceil(r/tick)` already differ by exactly 1 tick. minSpreadBps is just + # the floor before tick rounding; 0 would still yield 1-tick spread on + # off-tick mids, but a tiny non-zero value protects the rare exact-tick case. + minSpreadBps: 0 + # Disable vol-based spread widening so the spread stays at the tick floor. + volatilityMultiplier: 0 maxSkewTicks: 0 sizing: strategy: geometric-taper - baseQuantity: "100000000" # 100 USDC per side total + baseQuantity: 8 # venue-native (contracts base units) numLevelsPerSide: 4 taperRatio: 0.6 risk: - maxPositionSize: "50000000" + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" - maxDailyLossUsd: "500000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 100 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -54,15 +63,16 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 10 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 1 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 500 + maxCollateralAmount: 1000 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index b5cd7f8..41f864d 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -6,16 +6,19 @@ nodeEnv: development commitHash: ${COMMIT_HASH:-dev} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-true} +logLevel: debug +dryRun: false +# Local/dev iterates fast; leave resting orders on the hardhat book on Ctrl-C +# so you don't pay the cancel-then-reopen round-trip on every restart. +cancelOrdersOnShutdown: false wallets: primary: privateKey: ${PRIVATE_KEY} network: - name: ${NETWORK:-hardhat} - rpcUrl: ${ETH_NODE_ADDRESS:-http://127.0.0.1:8545} + name: "hardhat" + rpcUrl: "http://127.0.0.1:8545" ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -26,24 +29,24 @@ venue: pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 + marginCallTimeSec: 3600 minSpreadBps: 20 volatilityMultiplier: 2.5 maxSkewTicks: 0 sizing: strategy: geometric-taper - baseQuantity: "10000000" # 10 USDC per side total — tiny + baseQuantity: "10000000" # venue-native (contracts base units) numLevelsPerSide: 3 taperRatio: 0.6 risk: - maxPositionSize: "10000000" + maxPositionSize: 10 maxUtilizationPct: 80 - minCollateralBalance: "1000000" - maxDailyLossUsd: "100000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 1 + maxDailyLossUsd: 100 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -52,15 +55,15 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 1 collateral: autoDeposit: false - autoDepositMinAmount: "0" + autoDepositMinAmount: 0 health: - port: ${MAKER_HEALTH_PORT:-3001} + port: 3001 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index cfcea7c..7ab33a2 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -2,7 +2,7 @@ # Titan Market Maker - Futures - PRODUCTION (base-mainnet). # # PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) # ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet @@ -10,6 +10,9 @@ nodeEnv: production commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-info} dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} wallets: primary: @@ -17,7 +20,7 @@ wallets: network: name: base - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -28,24 +31,24 @@ venue: pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 + marginCallTimeSec: 3600 minSpreadBps: 15 volatilityMultiplier: 2.5 maxSkewTicks: 0 sizing: strategy: geometric-taper - baseQuantity: "500000000" # 500 USDC per side total + baseQuantity: "500000000" # venue-native (contracts base units) numLevelsPerSide: 4 taperRatio: 0.6 risk: - maxPositionSize: "1000000000" # 1000 USDC + maxPositionSize: 1000 maxUtilizationPct: 75 - minCollateralBalance: "100000000" - maxDailyLossUsd: "1000000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 100 + maxDailyLossUsd: 1000 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -54,15 +57,15 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 1 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 1 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 8343463..91a9b65 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -2,7 +2,7 @@ # Titan Market Maker - Futures - STAGING (base-mainnet). # # PRIVATE_KEY - hex private key of the staging market-making wallet -# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) # ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet @@ -10,6 +10,9 @@ nodeEnv: staging commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-debug} dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} wallets: primary: @@ -17,7 +20,7 @@ wallets: network: name: base - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -28,24 +31,24 @@ venue: pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 + marginCallTimeSec: 3600 minSpreadBps: 15 volatilityMultiplier: 2.5 maxSkewTicks: 0 sizing: strategy: geometric-taper - baseQuantity: "100000000" # 100 USDC per side total + baseQuantity: "100000000" # venue-native (contracts base units) numLevelsPerSide: 4 taperRatio: 0.6 risk: - maxPositionSize: "50000000" + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" - maxDailyLossUsd: "500000000" - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -54,15 +57,15 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 1 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 1 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 98558e3..725363c 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -5,7 +5,7 @@ # on so the wallet stays funded. # # PRIVATE_KEY - hex private key of the dev market-making wallet -# ETH_NODE_ADDRESS - base-sepolia JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia # ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia @@ -13,6 +13,9 @@ nodeEnv: development commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-debug} dryRun: ${MAKER_DRY_RUN:-false} +# Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we +# don't burn gas on cancel-then-reopen across every restart. +cancelOrdersOnShutdown: false wallets: primary: @@ -20,7 +23,7 @@ wallets: network: name: base-sepolia - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -30,23 +33,27 @@ venue: pricing: strategy: effective-spread - minSpreadBps: 15 - volatilityMultiplier: 2.0 + # The MM reads the unrounded oracle answer (PerpsVenue.getRawMarketPrice), + # so the mid lands between ticks and tick-rounding alone produces 1-tick + # bid/ask separation. A non-zero floor still buys insurance for the rare + # case `r` lands exactly on a tick. + minSpreadBps: 0 + volatilityMultiplier: 0 inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: strategy: linear - baseQuantity: "1000000" # 1 USDC per level + baseQuantity: "1000000" # venue-native units (hashrate base) numLevelsPerSide: 5 risk: - maxPositionSize: "50000000" # 50 USDC + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" # 10 USDC - maxDailyLossUsd: "500000000" # 500 USDC - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -55,15 +62,20 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 30 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 - levelSpacingTicks: 5 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + # Perps matching is "limit" — deeper levels fill conditional on shallower + # ones being hit first, so production normally wants levelSpacing ≥ 3 to + # spread inventory risk along the book. In dev we keep it tight (1 tick) + # to match futures and visually verify the level layout. + levelSpacingTicks: 1 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 500 + maxCollateralAmount: 1000 health: - port: ${MAKER_HEALTH_PORT:-3001} + port: ${MAKER_HEALTH_PORT:-3002} diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index 56c18ab..a7ead6a 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -13,6 +13,9 @@ nodeEnv: development commitHash: ${COMMIT_HASH:-dev} logLevel: ${MAKER_LOG_LEVEL:-debug} dryRun: ${MAKER_DRY_RUN:-true} +# Local/dev iterates fast; leave resting orders on the hardhat book on Ctrl-C +# so you don't pay the cancel-then-reopen round-trip on every restart. +cancelOrdersOnShutdown: false wallets: primary: @@ -20,7 +23,7 @@ wallets: network: name: ${NETWORK:-hardhat} - rpcUrl: ${ETH_NODE_ADDRESS:-http://127.0.0.1:8545} + rpcUrl: http://127.0.0.1:8545 ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -37,16 +40,17 @@ pricing: sizing: strategy: linear - baseQuantity: "100000" # 0.1 USDC per level — tiny + baseQuantity: "100000" # venue-native units (hashrate base) numLevelsPerSide: 3 risk: - maxPositionSize: "10000000" # 10 USDC + # All *Usd fields are USD (decimals OK). Loader converts to 6-dec USDC. + maxPositionSize: 10 maxUtilizationPct: 80 - minCollateralBalance: "1000000" # 1 USDC - maxDailyLossUsd: "100000000" # 100 USDC - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 1 + maxDailyLossUsd: 100 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -55,16 +59,17 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + # All *Sec fields are seconds (decimals OK). Loader converts to ms. + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 5 collateral: # Off in dev so you can inspect un-deposited wallet balance. autoDeposit: false - autoDepositMinAmount: "0" + autoDepositMinAmount: 0 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index b33fc45..2962a9f 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -6,7 +6,7 @@ # liquidity provision target. # # PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) # ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet (USD-denominated risk gates) @@ -14,6 +14,9 @@ nodeEnv: production commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-info} dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} wallets: primary: @@ -21,7 +24,7 @@ wallets: network: name: base - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -38,16 +41,16 @@ pricing: sizing: strategy: linear - baseQuantity: "10000000" # 10 USDC per level + baseQuantity: "10000000" # venue-native units (hashrate base) numLevelsPerSide: 5 risk: - maxPositionSize: "1000000000" # 1000 USDC + maxPositionSize: 1000 maxUtilizationPct: 75 # tighter than dev/stg - minCollateralBalance: "100000000" # 100 USDC operational floor - maxDailyLossUsd: "1000000000" # 1000 USDC daily loss circuit-breaker - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 100 # operational floor + maxDailyLossUsd: 1000 # daily loss circuit-breaker + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -56,15 +59,15 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 5 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 1 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index c70992d..f9e2361 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -5,7 +5,7 @@ # logs to make incident triage easier in shared infra. # # PRIVATE_KEY - hex private key of the staging market-making wallet -# ETH_NODE_ADDRESS - base-mainnet JSON-RPC endpoint +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) # PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) # ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet @@ -13,6 +13,9 @@ nodeEnv: staging commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-debug} dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} wallets: primary: @@ -20,7 +23,7 @@ wallets: network: name: base - rpcUrl: ${ETH_NODE_ADDRESS} + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} venue: @@ -37,16 +40,16 @@ pricing: sizing: strategy: linear - baseQuantity: "1000000" # 1 USDC per level + baseQuantity: "1000000" # venue-native units (hashrate base) numLevelsPerSide: 5 risk: - maxPositionSize: "50000000" # 50 USDC + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" # 10 USDC - maxDailyLossUsd: "500000000" # 500 USDC - maxGasBudgetPerHourUsd: "50000000" - maxGasBudgetPerDayUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 gasPenaltyBps: 5 urgentRequoteThresholdTicks: 10 @@ -55,15 +58,15 @@ gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 requoteThresholdTicks: 2 - requoteCooldownMs: 1000 - resyncIntervalMs: 60000 + requoteCooldownSec: 1 + resyncIntervalSec: 60 levelSpacingTicks: 5 collateral: autoDeposit: true - autoDepositMinAmount: "1000000" + autoDepositMinAmount: 1 health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/infra/README.md b/market-maker/infra/README.md index aa4ba58..8b0b17c 100644 --- a/market-maker/infra/README.md +++ b/market-maker/infra/README.md @@ -10,10 +10,10 @@ config file. │ ECS service: market-maker-perps │ │ ECS service: market-maker-futures │ │ image: titan-market-maker:sha │ │ image: titan-market-maker:sha │ │ env: MAKER_APP=perps │ │ env: MAKER_APP=futures │ -│ MAKER_CONFIG=/configs/perps.yml │ MAKER_CONFIG=/configs/futures.yml │ -│ secrets: PRIVATE_KEY (perps) │ │ secrets: PRIVATE_KEY (futures) │ -│ ETH_NODE_ADDRESS │ │ ETH_NODE_ADDRESS │ +│ MAKER_ENV=prd │ │ MAKER_ENV=prd │ │ PERPS_ADDRESS │ │ FUTURES_ADDRESS │ +│ secrets: PRIVATE_KEY (perps) │ │ secrets: PRIVATE_KEY (futures) │ +│ ALCHEMY_API_KEY │ │ ALCHEMY_API_KEY │ └────────────────────────────────────┘ └────────────────────────────────────┘ ``` diff --git a/market-maker/infra/ecs-task.tf b/market-maker/infra/ecs-task.tf index bbe9fe0..f435d33 100644 --- a/market-maker/infra/ecs-task.tf +++ b/market-maker/infra/ecs-task.tf @@ -4,14 +4,16 @@ # (perps/.bedrock/.terragrunt/ or futures-marketplace/.bedrock/.terragrunt/) # and parameterise per venue: # -# - var.maker_app "perps" | "futures" -# - var.maker_env "dev" | "stg" | "prd" — picks configs/..yml -# - var.image_tag git-sha pinned image tag -# - var.contract_address PERPS_ADDRESS or FUTURES_ADDRESS -# - var.network "arbitrum" | "base" | etc. -# - var.eth_node_address JSON-RPC or websocket endpoint -# - var.eth_price_feed_address optional Chainlink ETH/USD feed -# - var.private_key_secret_arn AWS Secrets Manager ARN with the wallet PK +# - var.maker_app "perps" | "futures" +# - var.maker_env "dev" | "stg" | "prd" — picks configs/..yml +# - var.image_tag git-sha pinned image tag +# - var.contract_address PERPS_ADDRESS or FUTURES_ADDRESS +# - var.eth_price_feed_address optional Chainlink ETH/USD feed +# - var.alchemy_api_key_secret_arn AWS Secrets Manager ARN with the Alchemy API key +# - var.private_key_secret_arn AWS Secrets Manager ARN with the wallet PK +# +# The bundled YAMLs interpolate the RPC URL from ALCHEMY_API_KEY, so we +# inject that via Secrets Manager rather than passing the full URL. # # The task def expects the image at: # ghcr.io/lumerin-protocol/titan-market-maker:${var.image_tag} @@ -58,7 +60,10 @@ resource "aws_iam_role_policy" "market_maker_secrets_access" { Statement = [{ Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] - Resource = [var.private_key_secret_arn] + Resource = [ + var.private_key_secret_arn, + var.alchemy_api_key_secret_arn, + ] }] }) } @@ -84,14 +89,13 @@ resource "aws_ecs_task_definition" "market_maker" { { name = "MAKER_CONFIG", value = "/app/configs/${var.maker_app}.${var.maker_env}.yml" }, { name = "NODE_ENV", value = "production" }, { name = "MAKER_LOG_LEVEL", value = "info" }, - { name = "NETWORK", value = var.network }, - { name = "ETH_NODE_ADDRESS", value = var.eth_node_address }, { name = "ETH_PRICE_FEED_ADDRESS", value = var.eth_price_feed_address }, { name = var.maker_app == "perps" ? "PERPS_ADDRESS" : "FUTURES_ADDRESS", value = var.contract_address }, ] secrets = [ { name = "PRIVATE_KEY", valueFrom = var.private_key_secret_arn }, + { name = "ALCHEMY_API_KEY", valueFrom = var.alchemy_api_key_secret_arn }, ] portMappings = [{ diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 818dbc6..9c57420 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -2,12 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Titan Market Maker - Futures config", "additionalProperties": false, + "description": "Titan Market Maker — Futures app config.", "type": "object", "required": [ "nodeEnv", "commitHash", "logLevel", "dryRun", + "cancelOrdersOnShutdown", "wallets", "network", "venue", @@ -22,14 +24,17 @@ "properties": { "nodeEnv": { "default": "development", + "description": "Environment label (development/staging/production). Used for log enrichment only.", "type": "string" }, "commitHash": { "default": "unknown", + "description": "Build-time commit SHA; surfaced via /healthz for ops correlation.", "type": "string" }, "logLevel": { "default": "info", + "description": "Pino log level (trace/debug/info/warn/error/fatal).", "type": "string" }, "dryRun": { @@ -43,13 +48,30 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "If true, all order writes are skipped — quotes are computed but not submitted.", "default": false }, + "cancelOrdersOnShutdown": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", + "default": true + }, "wallets": { + "description": "Map of named signer wallets; venue.wallet selects which one signs.", "type": "object", "patternProperties": { "^(.*)$": { "additionalProperties": false, + "description": "Named signer wallet. Referenced by venue.wallet.", "type": "object", "required": [ "privateKey" @@ -66,7 +88,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Hex-encoded ECDSA private key for the signer." } } } @@ -74,6 +97,7 @@ }, "network": { "additionalProperties": false, + "description": "Network connection settings.", "type": "object", "required": [ "name", @@ -81,12 +105,15 @@ ], "properties": { "name": { + "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", "type": "string" }, "rpcUrl": { + "description": "JSON-RPC endpoint URL for reads and tx submission.", "type": "string" }, "ethPriceFeed": { + "description": "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", "anyOf": [ { "const": "", @@ -111,6 +138,7 @@ }, "venue": { "additionalProperties": false, + "description": "Futures venue identification and signer selection.", "type": "object", "required": [ "kind", @@ -119,6 +147,7 @@ ], "properties": { "kind": { + "description": "Venue type — must be 'futures' for the Futures contract.", "const": "futures", "type": "string" }, @@ -133,26 +162,30 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Deployed Futures contract address." }, "wallet": { + "description": "Key in the top-level `wallets` map identifying the signer for this venue.", "type": "string" } } }, "pricing": { "additionalProperties": false, + "description": "Reservation-price pricing parameters.", "type": "object", "required": [ "strategy", "riskAversion", - "marginCallTimeSeconds", + "marginCallTimeSec", "minSpreadBps", "volatilityMultiplier", "maxSkewTicks" ], "properties": { "strategy": { + "description": "Pricing strategy. Futures lock to 'reservation-price' (Avellaneda–Stoikov inventory skew).", "const": "reservation-price", "type": "string" }, @@ -167,9 +200,10 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Avellaneda–Stoikov risk aversion γ. Higher = stronger inventory skew." }, - "marginCallTimeSeconds": { + "marginCallTimeSec": { "anyOf": [ { "minimum": 0, @@ -180,7 +214,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable." }, "minSpreadBps": { "anyOf": [ @@ -193,7 +228,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." }, "volatilityMultiplier": { "anyOf": [ @@ -206,17 +242,20 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Multiplier applied to realized volatility when widening the spread." }, "maxSkewTicks": { "const": 0, "default": 0, + "description": "Pinned to 0 — under reservation-price the skew is encoded in r itself.", "type": "number" } } }, "sizing": { "additionalProperties": false, + "description": "Geometric-taper sizing parameters.", "type": "object", "required": [ "strategy", @@ -226,11 +265,39 @@ ], "properties": { "strategy": { + "description": "Sizing strategy. Futures lock to 'geometric-taper' (front level largest, decays by taperRatio).", "const": "geometric-taper", "type": "string" }, "baseQuantity": { - "type": "string" + "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "numLevelsPerSide": { "anyOf": [ @@ -243,7 +310,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Number of price levels quoted per side." }, "taperRatio": { "anyOf": [ @@ -257,12 +325,14 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." } } }, "risk": { "additionalProperties": false, + "description": "Risk caps, circuit-breakers, and gas-price guards.", "type": "object", "required": [ "maxPositionSize", @@ -277,7 +347,34 @@ ], "properties": { "maxPositionSize": { - "type": "string" + "description": "USD. Hard cap on |net position notional|. Beyond this, only risk-reducing quotes are placed.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxUtilizationPct": { "anyOf": [ @@ -292,21 +389,130 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Margin utilization (used IM / vault balance) above which only risk-reducing quotes are placed.", "default": 80 }, "minCollateralBalance": { - "type": "string" + "description": "USD. Operational floor; halts quoting when vault balance falls below this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxDailyLossUsd": { - "type": "string" + "description": "USD. Daily PnL circuit-breaker. Halts quoting when realized loss + gas exceeds this since 00:00 UTC.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxGasBudgetPerHourUsd": { - "default": "50000000", - "type": "string" + "default": 50, + "description": "USD. Soft throttle: when hourly gas spend exceeds this, requote cooldown triples.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxGasBudgetPerDayUsd": { - "default": "500000000", - "type": "string" + "default": 500, + "description": "USD. Hard halt: stops requoting once daily gas spend exceeds this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "gasSpikeThresholdPct": { "anyOf": [ @@ -319,6 +525,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Percent of baseline. Quotes pause when current gas price exceeds (baseline × pct/100).", "default": 200 }, "gasPenaltyBps": { @@ -332,6 +539,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Bps to widen spreads by per unit of gas-cost-as-fraction-of-notional (compensates for fill economics).", "default": 5 }, "urgentRequoteThresholdTicks": { @@ -345,12 +553,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", "default": 10 } } }, "gas": { "additionalProperties": false, + "description": "Gas-pricing knobs.", "type": "object", "required": [ "gasCapMultiplier" @@ -367,12 +577,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Multiplier on viem-suggested gas price for the maxFeePerGas cap. Higher = more reliable inclusion at higher cost.", "default": 2 } } }, "collateral": { "additionalProperties": false, + "description": "Collateral vault behaviour.", "type": "object", "required": [ "autoDeposit", @@ -390,38 +602,116 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "If true, sweeps wallet token balance into the vault on each loop iteration (subject to min/max).", "default": false }, "autoDepositMinAmount": { - "default": "0", - "type": "string" + "default": 0, + "description": "USD. Trigger threshold: deposit fires only when wallet balance ≥ this. Dust filter to avoid wasting gas on tiny sweeps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxCollateralAmount": { + "description": "USD. Optional ceiling on the total vault balance held by this MM. Each auto-deposit brings the vault up to (but not above) this value; the wallet retains anything beyond it. Omit for no ceiling.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] } } }, "timing": { "additionalProperties": false, + "description": "Loop cadences and requote thresholds.", "type": "object", "required": [ - "pollIntervalMs", + "pollIntervalSec", "requoteThresholdTicks", - "requoteCooldownMs", - "resyncIntervalMs", + "requoteCooldownSec", + "resyncIntervalSec", "levelSpacingTicks" ], "properties": { - "pollIntervalMs": { + "pollIntervalSec": { + "minimum": 0.1, + "default": 3, + "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ { - "minimum": 100, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 0.1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 3000 + ] }, "requoteThresholdTicks": { "anyOf": [ @@ -435,35 +725,74 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Tick deviation from current target before a resting order is replaced.", "default": 2 }, - "requoteCooldownMs": { + "requoteCooldownSec": { + "minimum": 0, + "default": 1, + "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ { - "minimum": 0, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 1000 + ] }, - "resyncIntervalMs": { + "resyncIntervalSec": { + "minimum": 1, + "default": 60, + "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ { - "minimum": 1000, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 60000 + ] }, "levelSpacingTicks": { "anyOf": [ @@ -477,12 +806,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", "default": 1 } } }, "health": { "additionalProperties": false, + "description": "Health-check HTTP server.", "type": "object", "required": [ "port" @@ -500,6 +831,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "TCP port for the /healthz HTTP endpoint.", "default": 3001 } } diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 01e6f6d..4525883 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -2,12 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Titan Market Maker - Perps config", "additionalProperties": false, + "description": "Titan Market Maker — Perps app config.", "type": "object", "required": [ "nodeEnv", "commitHash", "logLevel", "dryRun", + "cancelOrdersOnShutdown", "wallets", "network", "venue", @@ -22,14 +24,17 @@ "properties": { "nodeEnv": { "default": "development", + "description": "Environment label (development/staging/production). Used for log enrichment only.", "type": "string" }, "commitHash": { "default": "unknown", + "description": "Build-time commit SHA; surfaced via /healthz for ops correlation.", "type": "string" }, "logLevel": { "default": "info", + "description": "Pino log level (trace/debug/info/warn/error/fatal).", "type": "string" }, "dryRun": { @@ -43,13 +48,30 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "If true, all order writes are skipped — quotes are computed but not submitted.", "default": false }, + "cancelOrdersOnShutdown": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", + "default": true + }, "wallets": { + "description": "Map of named signer wallets; venue.wallet selects which one signs.", "type": "object", "patternProperties": { "^(.*)$": { "additionalProperties": false, + "description": "Named signer wallet. Referenced by venue.wallet.", "type": "object", "required": [ "privateKey" @@ -66,7 +88,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Hex-encoded ECDSA private key for the signer." } } } @@ -74,6 +97,7 @@ }, "network": { "additionalProperties": false, + "description": "Network connection settings.", "type": "object", "required": [ "name", @@ -81,12 +105,15 @@ ], "properties": { "name": { + "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", "type": "string" }, "rpcUrl": { + "description": "JSON-RPC endpoint URL for reads and tx submission.", "type": "string" }, "ethPriceFeed": { + "description": "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", "anyOf": [ { "const": "", @@ -111,6 +138,7 @@ }, "venue": { "additionalProperties": false, + "description": "Perps venue identification and signer selection.", "type": "object", "required": [ "kind", @@ -119,6 +147,7 @@ ], "properties": { "kind": { + "description": "Venue type — must be 'perps' for HashPowerPerpsDEX.", "const": "perps", "type": "string" }, @@ -133,15 +162,18 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Deployed HashPowerPerpsDEX contract address." }, "wallet": { + "description": "Key in the top-level `wallets` map identifying the signer for this venue.", "type": "string" } } }, "pricing": { "additionalProperties": false, + "description": "Effective-spread pricing parameters.", "type": "object", "required": [ "strategy", @@ -152,6 +184,7 @@ ], "properties": { "strategy": { + "description": "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", "const": "effective-spread", "type": "string" }, @@ -166,7 +199,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." }, "volatilityMultiplier": { "anyOf": [ @@ -179,7 +213,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Multiplier applied to realized volatility when widening the spread." }, "inventorySkewGamma": { "anyOf": [ @@ -192,7 +227,8 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Inventory skew coefficient. Quotes shift by γ × (netPos / maxPos) ticks toward unwinding." }, "maxSkewTicks": { "anyOf": [ @@ -205,12 +241,14 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Cap on absolute ticks a level can be skewed from the symmetric mid." } } }, "sizing": { "additionalProperties": false, + "description": "Linear-ladder sizing parameters.", "type": "object", "required": [ "strategy", @@ -219,11 +257,39 @@ ], "properties": { "strategy": { + "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", "const": "linear", "type": "string" }, "baseQuantity": { - "type": "string" + "description": "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "numLevelsPerSide": { "anyOf": [ @@ -236,12 +302,14 @@ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", "description": "Environment variable interpolation (resolved at startup)" } - ] + ], + "description": "Number of price levels quoted per side." } } }, "risk": { "additionalProperties": false, + "description": "Risk caps, circuit-breakers, and gas-price guards.", "type": "object", "required": [ "maxPositionSize", @@ -256,7 +324,34 @@ ], "properties": { "maxPositionSize": { - "type": "string" + "description": "USD. Hard cap on |net position notional|. Beyond this, only risk-reducing quotes are placed.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxUtilizationPct": { "anyOf": [ @@ -271,21 +366,130 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Margin utilization (used IM / vault balance) above which only risk-reducing quotes are placed.", "default": 80 }, "minCollateralBalance": { - "type": "string" + "description": "USD. Operational floor; halts quoting when vault balance falls below this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxDailyLossUsd": { - "type": "string" + "description": "USD. Daily PnL circuit-breaker. Halts quoting when realized loss + gas exceeds this since 00:00 UTC.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxGasBudgetPerHourUsd": { - "default": "50000000", - "type": "string" + "default": 50, + "description": "USD. Soft throttle: when hourly gas spend exceeds this, requote cooldown triples.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "maxGasBudgetPerDayUsd": { - "default": "500000000", - "type": "string" + "default": 500, + "description": "USD. Hard halt: stops requoting once daily gas spend exceeds this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] }, "gasSpikeThresholdPct": { "anyOf": [ @@ -298,6 +502,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Percent of baseline. Quotes pause when current gas price exceeds (baseline × pct/100).", "default": 200 }, "gasPenaltyBps": { @@ -311,6 +516,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Bps to widen spreads by per unit of gas-cost-as-fraction-of-notional (compensates for fill economics).", "default": 5 }, "urgentRequoteThresholdTicks": { @@ -324,12 +530,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", "default": 10 } } }, "gas": { "additionalProperties": false, + "description": "Gas-pricing knobs.", "type": "object", "required": [ "gasCapMultiplier" @@ -346,12 +554,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Multiplier on viem-suggested gas price for the maxFeePerGas cap. Higher = more reliable inclusion at higher cost.", "default": 2 } } }, "collateral": { "additionalProperties": false, + "description": "Collateral vault behaviour.", "type": "object", "required": [ "autoDeposit", @@ -369,38 +579,116 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "If true, sweeps wallet token balance into the vault on each loop iteration (subject to min/max).", "default": false }, "autoDepositMinAmount": { - "default": "0", - "type": "string" + "default": 0, + "description": "USD. Trigger threshold: deposit fires only when wallet balance ≥ this. Dust filter to avoid wasting gas on tiny sweeps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxCollateralAmount": { + "description": "USD. Optional ceiling on the total vault balance held by this MM. Each auto-deposit brings the vault up to (but not above) this value; the wallet retains anything beyond it. Omit for no ceiling.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] } } }, "timing": { "additionalProperties": false, + "description": "Loop cadences and requote thresholds.", "type": "object", "required": [ - "pollIntervalMs", + "pollIntervalSec", "requoteThresholdTicks", - "requoteCooldownMs", - "resyncIntervalMs", + "requoteCooldownSec", + "resyncIntervalSec", "levelSpacingTicks" ], "properties": { - "pollIntervalMs": { + "pollIntervalSec": { + "minimum": 0.1, + "default": 3, + "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ { - "minimum": 100, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 0.1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 3000 + ] }, "requoteThresholdTicks": { "anyOf": [ @@ -414,35 +702,74 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Tick deviation from current target before a resting order is replaced.", "default": 2 }, - "requoteCooldownMs": { + "requoteCooldownSec": { + "minimum": 0, + "default": 1, + "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ { - "minimum": 0, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 1000 + ] }, - "resyncIntervalMs": { + "resyncIntervalSec": { + "minimum": 1, + "default": 60, + "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ { - "minimum": 1000, - "type": "number" + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] }, { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] } - ], - "default": 60000 + ] }, "levelSpacingTicks": { "anyOf": [ @@ -456,12 +783,14 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", "default": 1 } } }, "health": { "additionalProperties": false, + "description": "Health-check HTTP server.", "type": "object", "required": [ "port" @@ -479,6 +808,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "TCP port for the /healthz HTTP endpoint.", "default": 3001 } } diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 3443a59..63b4a96 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -11,10 +11,13 @@ export class FuturesVenueEvents implements VenueEvents { private listeners = new Set<(event: VenueEvent) => void>(); private unwatch: WatchContractEventReturnType | null = null; - constructor( - private readonly publicClient: PublicClient, - private readonly address: `0x${string}`, - ) {} + private readonly publicClient: PublicClient; + private readonly address: `0x${string}`; + + constructor(publicClient: PublicClient, address: `0x${string}`) { + this.publicClient = publicClient; + this.address = address; + } subscribe(cb: (event: VenueEvent) => void): Unsubscribe { this.listeners.add(cb); diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 710838a..b8699b0 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -35,11 +35,12 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } async getIndexPrice(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "getMarketPrice", - }); + // Read the raw oracle answer rebased to token decimals — `Futures.getMarketPrice` + // would round to the nearest tick, which collapses our reservation-price + // shift onto a tick boundary and forces a 2-tick min spread. The unrounded + // mid lets `roundDownToTick(r) → bidMid` and `roundUpToTick(r) → askMid` + // produce a 1-tick spread naturally. + return await this.venue.getRawMarketPrice(); } async getPosition(): Promise { @@ -47,14 +48,20 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { // engine view exposed for this purpose: getNetPositionDelta returns // `Σ qty_i * deliveryDurationDays` × 1e18 in WAD. Convert back to // contracts by dividing by `deliveryDurationDays * 1e18`. - const [netDeltaWad, durationDays, marketPrice] = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: [ - { address: this.venue.address, abi: FuturesAbi, functionName: "getNetPositionDelta", args: [this.venue.wallet.account.address] }, - { address: this.venue.address, abi: FuturesAbi, functionName: "deliveryDurationDays" }, - { address: this.venue.address, abi: FuturesAbi, functionName: "getMarketPrice" }, - ], - }); + const [netDeltaWad, durationDays, marketPrice] = await Promise.all([ + this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getNetPositionDelta", + args: [this.venue.wallet.account.address], + }), + this.venue.publicClient.readContract({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "deliveryDurationDays", + }), + this.venue.getRawMarketPrice(), + ]); const days = BigInt(durationDays); const denom = days * 10n ** 18n; const netQuantity = denom === 0n ? 0n : netDeltaWad / denom; @@ -163,7 +170,10 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { */ class FuturesBook implements BookSource { readonly matchingMode: MatchingMode = "exact"; - constructor(private readonly inst: FuturesInstrumentAdapter) {} + private readonly inst: FuturesInstrumentAdapter; + constructor(inst: FuturesInstrumentAdapter) { + this.inst = inst; + } async tick(): Promise { return this.inst.getMinTick(); diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 50968de..54b642e 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -15,6 +15,7 @@ import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "../../abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; +import { RawOracleReader } from "../../core/rawOracle.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; import { FuturesVenueEvents } from "./events.ts"; @@ -59,6 +60,7 @@ export class FuturesVenueAdapter implements VenueAdapter { private collateralTokenCache: `0x${string}` | null = null; private deliveryDurationDaysCache: bigint | null = null; private marginPercentCache: bigint | null = null; + private readonly rawOracle: RawOracleReader; constructor(opts: FuturesVenueOptions) { this.wallet = opts.wallet; @@ -74,6 +76,24 @@ export class FuturesVenueAdapter implements VenueAdapter { this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); + + // Discover (oracle, divisor) from the futures contract on first read. + // The divisor is precomputed on chain (`hashpriceScalingDivisor`), so we + // just fetch both fields together. + this.rawOracle = new RawOracleReader({ + publicClient: this.publicClient, + label: "futures", + resolve: async () => { + const [oracle, divisor] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: this.address, abi: FuturesAbi, functionName: "hashrateOracle" }, + { address: this.address, abi: FuturesAbi, functionName: "hashpriceScalingDivisor" }, + ], + }); + return { oracle, divisor }; + }, + }); } async getInstrument(): Promise { @@ -131,6 +151,14 @@ export class FuturesVenueAdapter implements VenueAdapter { return this.logger; } + /** + * Latest hashprice oracle answer rebased to token decimals (no tick + * rounding). See `RawOracleReader` for rationale. + */ + getRawMarketPrice(): Promise { + return this.rawOracle.read(); + } + /** * Cache delivery-duration-days and marginPercent on the venue. Both are * static-ish (admin-changeable) so we read them once and reuse for the @@ -167,7 +195,10 @@ export class FuturesVenueAdapter implements VenueAdapter { * unrealized PnL (signed), wallet ERC20 balance, native ETH balance. */ class FuturesCollateralAccount implements CollateralAccount { - constructor(private readonly venue: FuturesVenueAdapter) {} + private readonly venue: FuturesVenueAdapter; + constructor(venue: FuturesVenueAdapter) { + this.venue = venue; + } async snapshot(): Promise { const owner = this.venue.wallet.account.address; diff --git a/market-maker/src/adapters/perps/events.ts b/market-maker/src/adapters/perps/events.ts index 4451e34..11e8d1b 100644 --- a/market-maker/src/adapters/perps/events.ts +++ b/market-maker/src/adapters/perps/events.ts @@ -11,10 +11,13 @@ export class PerpsVenueEvents implements VenueEvents { private listeners = new Set<(event: VenueEvent) => void>(); private unwatch: WatchContractEventReturnType | null = null; - constructor( - private readonly publicClient: PublicClient, - private readonly address: `0x${string}`, - ) {} + private readonly publicClient: PublicClient; + private readonly address: `0x${string}`; + + constructor(publicClient: PublicClient, address: `0x${string}`) { + this.publicClient = publicClient; + this.address = address; + } subscribe(cb: (event: VenueEvent) => void): Unsubscribe { this.listeners.add(cb); diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 40c83f2..b9c7bf9 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -35,11 +35,12 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } async getIndexPrice(): Promise { - return await this.venue.publicClient.readContract({ - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getMarketPrice", - }); + // Read the raw oracle answer rebased to token decimals — `HashPowerPerpsDEX.getMarketPrice` + // would round to the nearest tick, which collapses our reservation-price + // shift onto a tick boundary and forces a 2-tick min spread. The unrounded + // mid lets `roundDownToTick(r) → bidMid` and `roundUpToTick(r) → askMid` + // produce a 1-tick spread naturally. + return await this.venue.getRawMarketPrice(); } async getPosition(): Promise { @@ -128,7 +129,10 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { class PerpsBook implements BookSource { readonly matchingMode: MatchingMode = "limit"; - constructor(private readonly inst: PerpsInstrumentAdapter) {} + private readonly inst: PerpsInstrumentAdapter; + constructor(inst: PerpsInstrumentAdapter) { + this.inst = inst; + } tick(): Promise { return this.inst.getMinTick(); @@ -176,7 +180,10 @@ class PerpsBook implements BookSource { * source of truth. */ class PerpsOwnOrders implements OwnOrderSource { - constructor(private readonly inst: PerpsInstrumentAdapter) {} + private readonly inst: PerpsInstrumentAdapter; + constructor(inst: PerpsInstrumentAdapter) { + this.inst = inst; + } async list(): Promise { const v = this.inst.venue; diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 6d031cb..9da37b6 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -15,6 +15,7 @@ import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "../../abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; +import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; import { PerpsInstrumentAdapter } from "./instrument.ts"; import { PerpsVenueEvents } from "./events.ts"; @@ -56,6 +57,7 @@ export class PerpsVenueAdapter implements VenueAdapter { private engineAddressCache: `0x${string}` | null = null; private collateralTokenCache: `0x${string}` | null = null; private imSpotShockCache: bigint | null = null; + private readonly rawOracle: RawOracleReader; constructor(opts: PerpsVenueOptions) { this.wallet = opts.wallet; @@ -71,6 +73,35 @@ export class PerpsVenueAdapter implements VenueAdapter { this.events = new PerpsVenueEvents(this.publicClient, this.address); this.account = new PerpsCollateralAccount(this); + + // Discover (oracle, divisor) on first read. Unlike futures the divisor + // isn't precomputed on chain — derive it from oracle.decimals() and the + // collateral token's decimals. + this.rawOracle = new RawOracleReader({ + publicClient: this.publicClient, + label: "perps", + resolve: async () => { + const { token } = await this.resolveAddresses(); + const oracle = await this.publicClient.readContract({ + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "priceOracle", + }); + const [oracleDecimals, tokenDecimals] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: oracle, abi: chainlinkAggregatorAbi, functionName: "decimals" }, + { address: token, abi: erc20Abi, functionName: "decimals" }, + ], + }); + if (tokenDecimals > oracleDecimals) { + throw new Error( + `perps: tokenDecimals (${tokenDecimals}) > oracleDecimals (${oracleDecimals})`, + ); + } + return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals) }; + }, + }); } async getInstrument(): Promise { @@ -120,6 +151,14 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.multicall3Address; } + /** + * Latest price oracle answer rebased to token decimals (no tick rounding). + * See `RawOracleReader` for rationale. + */ + getRawMarketPrice(): Promise { + return this.rawOracle.read(); + } + async fetchImSpotShock(): Promise { if (this.imSpotShockCache !== null) return this.imSpotShockCache; const { engine } = await this.resolveAddresses(); @@ -144,7 +183,10 @@ export class PerpsVenueAdapter implements VenueAdapter { * old `addCollateralWithPermit` path no longer exists on the contract. */ class PerpsCollateralAccount implements CollateralAccount { - constructor(private readonly venue: PerpsVenueAdapter) {} + private readonly venue: PerpsVenueAdapter; + constructor(venue: PerpsVenueAdapter) { + this.venue = venue; + } async snapshot(): Promise { const owner = this.venue.wallet.account.address; diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 66dc98c..82e5c3e 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -1,11 +1,18 @@ import { type Static, Type } from "@sinclair/typebox"; import { + type ParsedCollateralConfig, + type ParsedRiskConfig, + type ParsedTimingConfig, TypeEthAddress, collateralSchema, + configBigint, gasSchema, healthSchema, loadConfigFromFile, networkSchema, + parseCollateralConfig, + parseRiskConfig, + parseTimingConfig, riskSchema, timingSchema, walletSchema, @@ -27,46 +34,104 @@ const Closed = { additionalProperties: false }; const futuresVenueSchema = Type.Object( { - kind: Type.Literal("futures"), - address: TypeEthAddress(), - wallet: Type.String(), + kind: Type.Literal("futures", { + description: "Venue type — must be 'futures' for the Futures contract.", + }), + address: TypeEthAddress({ description: "Deployed Futures contract address." }), + wallet: Type.String({ + description: "Key in the top-level `wallets` map identifying the signer for this venue.", + }), }, - Closed, + { ...Closed, description: "Futures venue identification and signer selection." }, ); const futuresPricingSchema = Type.Object( { - strategy: Type.Literal("reservation-price"), - /** Avellaneda–Stoikov risk aversion γ. */ - riskAversion: Type.Number({ minimum: 0 }), - /** Fallback remaining time (seconds) when InstrumentContext.deliveryDate is unavailable. */ - marginCallTimeSeconds: Type.Number({ minimum: 0 }), - minSpreadBps: Type.Number({ minimum: 0 }), - volatilityMultiplier: Type.Number({ minimum: 0 }), - /** maxSkewTicks is unused under reservation-price (skew is in the formula); pinned at 0. */ - maxSkewTicks: Type.Number({ const: 0, default: 0 }), + strategy: Type.Literal("reservation-price", { + description: + "Pricing strategy. Futures lock to 'reservation-price' (Avellaneda–Stoikov inventory skew).", + }), + riskAversion: Type.Number({ + minimum: 0, + description: "Avellaneda–Stoikov risk aversion γ. Higher = stronger inventory skew.", + }), + marginCallTimeSec: Type.Number({ + minimum: 0, + description: + "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable.", + }), + minSpreadBps: Type.Number({ + minimum: 0, + description: "Floor on the half-spread in bps. Quotes never tighten below this.", + }), + volatilityMultiplier: Type.Number({ + minimum: 0, + description: "Multiplier applied to realized volatility when widening the spread.", + }), + maxSkewTicks: Type.Number({ + const: 0, + default: 0, + description: "Pinned to 0 — under reservation-price the skew is encoded in r itself.", + }), }, - Closed, + { ...Closed, description: "Reservation-price pricing parameters." }, ); +// `baseQuantity` is venue-native (futures: contract base units). Bigint +// expressed as a decimal string; numbers accepted but use strings if values +// exceed Number.MAX_SAFE_INTEGER. const futuresSizingSchema = Type.Object( { - strategy: Type.Literal("geometric-taper"), - /** Total per-side budget in token base units. */ - baseQuantity: Type.String(), - numLevelsPerSide: Type.Number({ minimum: 1 }), - taperRatio: Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1 }), + strategy: Type.Literal("geometric-taper", { + description: + "Sizing strategy. Futures lock to 'geometric-taper' (front level largest, decays by taperRatio).", + }), + baseQuantity: Type.Union( + [Type.String({ pattern: "^\\d+$" }), Type.Number()], + { + description: + "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + }, + ), + numLevelsPerSide: Type.Number({ + minimum: 1, + description: "Number of price levels quoted per side.", + }), + taperRatio: Type.Number({ + exclusiveMinimum: 0, + exclusiveMaximum: 1, + description: "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous.", + }), }, - Closed, + { ...Closed, description: "Geometric-taper sizing parameters." }, ); export const futuresRootSchema = Type.Object( { - nodeEnv: Type.String({ default: "development" }), - commitHash: Type.String({ default: "unknown" }), - logLevel: Type.String({ default: "info" }), - dryRun: Type.Boolean({ default: false }), - wallets: Type.Record(Type.String(), walletSchema), + nodeEnv: Type.String({ + default: "development", + description: "Environment label (development/staging/production). Used for log enrichment only.", + }), + commitHash: Type.String({ + default: "unknown", + description: "Build-time commit SHA; surfaced via /healthz for ops correlation.", + }), + logLevel: Type.String({ + default: "info", + description: "Pino log level (trace/debug/info/warn/error/fatal).", + }), + dryRun: Type.Boolean({ + default: false, + description: "If true, all order writes are skipped — quotes are computed but not submitted.", + }), + cancelOrdersOnShutdown: Type.Boolean({ + default: true, + description: + "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", + }), + wallets: Type.Record(Type.String(), walletSchema, { + description: "Map of named signer wallets; venue.wallet selects which one signs.", + }), network: networkSchema, venue: futuresVenueSchema, pricing: futuresPricingSchema, @@ -77,16 +142,34 @@ export const futuresRootSchema = Type.Object( timing: timingSchema, health: healthSchema, }, - Closed, + { ...Closed, description: "Titan Market Maker — Futures app config." }, ); -export type FuturesMakerConfig = Static; +type RawFuturesConfig = Static; + +/** Parsed futures config: bigints/ms substituted in for human-friendly inputs. */ +export type FuturesMakerConfig = Omit & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + sizing: Omit & { baseQuantity: bigint }; +}; export function loadFuturesConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): FuturesMakerConfig { - return loadConfigFromFile({ + return loadConfigFromFile({ schema: futuresRootSchema, path: opts.path, env: opts.env, + parse: (raw) => ({ + ...raw, + risk: parseRiskConfig(raw.risk), + timing: parseTimingConfig(raw.timing), + collateral: parseCollateralConfig(raw.collateral), + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), + }, + }), validate: (cfg) => { if (!cfg.wallets[cfg.venue.wallet]) { throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index 78ebf12..e3aa2ea 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -1,6 +1,5 @@ import pino from "pino"; import { loadDotenvFiles } from "../../core/env.ts"; -import { configBigint } from "../../core/config/base.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; @@ -52,25 +51,26 @@ async function main(): Promise { ); const inventory = new InventoryManager( instrument, - { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, + { maxPositionSize: config.risk.maxPositionSize }, logger, ); const collateral = new CollateralTracker( venue.account, { autoDeposit: config.collateral.autoDeposit, - autoDepositMinAmount: configBigint(config.collateral.autoDepositMinAmount, "collateral.autoDepositMinAmount"), + autoDepositMinAmount: config.collateral.autoDepositMinAmount, + maxCollateralAmount: config.collateral.maxCollateralAmount, }, logger, ); const risk = new RiskManager( { - maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), + maxPositionSize: config.risk.maxPositionSize, maxUtilizationPct: config.risk.maxUtilizationPct, - minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), - maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), - maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), - maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), + minCollateralBalance: config.risk.minCollateralBalance, + maxDailyLossUsd: config.risk.maxDailyLossUsd, + maxGasBudgetPerHourUsd: config.risk.maxGasBudgetPerHourUsd, + maxGasBudgetPerDayUsd: config.risk.maxGasBudgetPerDayUsd, }, inventory, collateral, @@ -84,21 +84,20 @@ async function main(): Promise { logger, ); - const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); const quoter = new Quoter( instrument, { pricing: { strategy: "reservation-price", riskAversion: config.pricing.riskAversion, - marginCallTimeSeconds: config.pricing.marginCallTimeSeconds, + marginCallTimeSeconds: config.pricing.marginCallTimeSec, minSpreadBps: config.pricing.minSpreadBps, volatilityMultiplier: config.pricing.volatilityMultiplier, gasPenaltyBps: config.risk.gasPenaltyBps, }, sizing: { strategy: "geometric-taper", - baseQuantity, + baseQuantity: config.sizing.baseQuantity, numLevelsPerSide: config.sizing.numLevelsPerSide, taperRatio: config.sizing.taperRatio, }, @@ -141,6 +140,7 @@ async function main(): Promise { await runMakerLoop({ pollIntervalMs: config.timing.pollIntervalMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, instrument, oracle, gas, diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 3bd2b26..3320926 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -1,11 +1,18 @@ import { type Static, Type } from "@sinclair/typebox"; import { + type ParsedCollateralConfig, + type ParsedRiskConfig, + type ParsedTimingConfig, TypeEthAddress, collateralSchema, + configBigint, gasSchema, healthSchema, loadConfigFromFile, networkSchema, + parseCollateralConfig, + parseRiskConfig, + parseTimingConfig, riskSchema, timingSchema, walletSchema, @@ -27,40 +34,90 @@ const Closed = { additionalProperties: false }; const perpsVenueSchema = Type.Object( { - kind: Type.Literal("perps"), - address: TypeEthAddress(), - wallet: Type.String(), + kind: Type.Literal("perps", { description: "Venue type — must be 'perps' for HashPowerPerpsDEX." }), + address: TypeEthAddress({ description: "Deployed HashPowerPerpsDEX contract address." }), + wallet: Type.String({ + description: "Key in the top-level `wallets` map identifying the signer for this venue.", + }), }, - Closed, + { ...Closed, description: "Perps venue identification and signer selection." }, ); const perpsPricingSchema = Type.Object( { - strategy: Type.Literal("effective-spread"), - minSpreadBps: Type.Number({ minimum: 0 }), - volatilityMultiplier: Type.Number({ minimum: 0 }), - inventorySkewGamma: Type.Number({ minimum: 0 }), - maxSkewTicks: Type.Number({ minimum: 0 }), + strategy: Type.Literal("effective-spread", { + description: "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", + }), + minSpreadBps: Type.Number({ + minimum: 0, + description: "Floor on the half-spread in bps. Quotes never tighten below this.", + }), + volatilityMultiplier: Type.Number({ + minimum: 0, + description: "Multiplier applied to realized volatility when widening the spread.", + }), + inventorySkewGamma: Type.Number({ + minimum: 0, + description: + "Inventory skew coefficient. Quotes shift by γ × (netPos / maxPos) ticks toward unwinding.", + }), + maxSkewTicks: Type.Number({ + minimum: 0, + description: "Cap on absolute ticks a level can be skewed from the symmetric mid.", + }), }, - Closed, + { ...Closed, description: "Effective-spread pricing parameters." }, ); +// `baseQuantity` is venue-native (perps: hashrate base units). It's a bigint +// expressed as a decimal string; numbers are accepted but use strings if +// values exceed Number.MAX_SAFE_INTEGER. const perpsSizingSchema = Type.Object( { - strategy: Type.Literal("linear"), - baseQuantity: Type.String(), - numLevelsPerSide: Type.Number({ minimum: 1 }), + strategy: Type.Literal("linear", { + description: "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", + }), + baseQuantity: Type.Union( + [Type.String({ pattern: "^\\d+$" }), Type.Number()], + { + description: + "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + }, + ), + numLevelsPerSide: Type.Number({ + minimum: 1, + description: "Number of price levels quoted per side.", + }), }, - Closed, + { ...Closed, description: "Linear-ladder sizing parameters." }, ); export const perpsRootSchema = Type.Object( { - nodeEnv: Type.String({ default: "development" }), - commitHash: Type.String({ default: "unknown" }), - logLevel: Type.String({ default: "info" }), - dryRun: Type.Boolean({ default: false }), - wallets: Type.Record(Type.String(), walletSchema), + nodeEnv: Type.String({ + default: "development", + description: "Environment label (development/staging/production). Used for log enrichment only.", + }), + commitHash: Type.String({ + default: "unknown", + description: "Build-time commit SHA; surfaced via /healthz for ops correlation.", + }), + logLevel: Type.String({ + default: "info", + description: "Pino log level (trace/debug/info/warn/error/fatal).", + }), + dryRun: Type.Boolean({ + default: false, + description: "If true, all order writes are skipped — quotes are computed but not submitted.", + }), + cancelOrdersOnShutdown: Type.Boolean({ + default: true, + description: + "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", + }), + wallets: Type.Record(Type.String(), walletSchema, { + description: "Map of named signer wallets; venue.wallet selects which one signs.", + }), network: networkSchema, venue: perpsVenueSchema, pricing: perpsPricingSchema, @@ -71,16 +128,34 @@ export const perpsRootSchema = Type.Object( timing: timingSchema, health: healthSchema, }, - Closed, + { ...Closed, description: "Titan Market Maker — Perps app config." }, ); -export type PerpsMakerConfig = Static; +type RawPerpsConfig = Static; + +/** Parsed perps config: bigints/ms substituted in for human-friendly inputs. */ +export type PerpsMakerConfig = Omit & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + sizing: Omit & { baseQuantity: bigint }; +}; export function loadPerpsConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): PerpsMakerConfig { - return loadConfigFromFile({ + return loadConfigFromFile({ schema: perpsRootSchema, path: opts.path, env: opts.env, + parse: (raw) => ({ + ...raw, + risk: parseRiskConfig(raw.risk), + timing: parseTimingConfig(raw.timing), + collateral: parseCollateralConfig(raw.collateral), + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), + }, + }), validate: (cfg) => { if (!cfg.wallets[cfg.venue.wallet]) { throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index d5659af..441f061 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -1,6 +1,5 @@ import pino from "pino"; import { loadDotenvFiles } from "../../core/env.ts"; -import { configBigint } from "../../core/config/base.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; @@ -47,25 +46,26 @@ async function main(): Promise { ); const inventory = new InventoryManager( instrument, - { maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize") }, + { maxPositionSize: config.risk.maxPositionSize }, logger, ); const collateral = new CollateralTracker( venue.account, { autoDeposit: config.collateral.autoDeposit, - autoDepositMinAmount: configBigint(config.collateral.autoDepositMinAmount, "collateral.autoDepositMinAmount"), + autoDepositMinAmount: config.collateral.autoDepositMinAmount, + maxCollateralAmount: config.collateral.maxCollateralAmount, }, logger, ); const risk = new RiskManager( { - maxPositionSize: configBigint(config.risk.maxPositionSize, "risk.maxPositionSize"), + maxPositionSize: config.risk.maxPositionSize, maxUtilizationPct: config.risk.maxUtilizationPct, - minCollateralBalance: configBigint(config.risk.minCollateralBalance, "risk.minCollateralBalance"), - maxDailyLossUsd: configBigint(config.risk.maxDailyLossUsd, "risk.maxDailyLossUsd"), - maxGasBudgetPerHourUsd: configBigint(config.risk.maxGasBudgetPerHourUsd, "risk.maxGasBudgetPerHourUsd"), - maxGasBudgetPerDayUsd: configBigint(config.risk.maxGasBudgetPerDayUsd, "risk.maxGasBudgetPerDayUsd"), + minCollateralBalance: config.risk.minCollateralBalance, + maxDailyLossUsd: config.risk.maxDailyLossUsd, + maxGasBudgetPerHourUsd: config.risk.maxGasBudgetPerHourUsd, + maxGasBudgetPerDayUsd: config.risk.maxGasBudgetPerDayUsd, }, inventory, collateral, @@ -79,7 +79,6 @@ async function main(): Promise { logger, ); - const baseQuantity = configBigint(config.sizing.baseQuantity, "sizing.baseQuantity"); const quoter = new Quoter( instrument, { @@ -92,7 +91,7 @@ async function main(): Promise { }, sizing: { strategy: "linear", - baseQuantity, + baseQuantity: config.sizing.baseQuantity, numLevelsPerSide: config.sizing.numLevelsPerSide, }, maxSkewTicks: config.pricing.maxSkewTicks, @@ -134,6 +133,7 @@ async function main(): Promise { await runMakerLoop({ pollIntervalMs: config.timing.pollIntervalMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, instrument, oracle, gas, diff --git a/market-maker/src/core/collateralTracker.ts b/market-maker/src/core/collateralTracker.ts index 0fdc9b4..b5bc227 100644 --- a/market-maker/src/core/collateralTracker.ts +++ b/market-maker/src/core/collateralTracker.ts @@ -9,8 +9,16 @@ export interface CollateralTrackerConfig { * false in dev/test where you want to inspect un-deposited balance. */ autoDeposit: boolean; - /** Skip the deposit if `walletTokenBalance < threshold`. */ + /** Trigger threshold: deposit only fires when `walletTokenBalance ≥ this`. */ autoDepositMinAmount: bigint; + /** + * Optional ceiling on the **total vault balance** held by this MM. When set, + * each top-up deposits at most `max(0, maxCollateralAmount − vaultBalance)`, + * so the MM never exceeds the configured collateral exposure regardless of + * how much sits in the wallet. Undefined → no ceiling, sweep the full + * wallet balance. + */ + maxCollateralAmount?: bigint; } /** @@ -60,9 +68,27 @@ export class CollateralTracker { async maybeTopUp(): Promise { if (!this.cfg.autoDeposit) return; + // Trigger gate: dust filter so we don't pay gas on a tiny sweep. if (this.walletTokenBalance < this.cfg.autoDepositMinAmount) return; - const amount = this.walletTokenBalance; - this.logger.info({ amount: amount.toString() }, "depositing wallet balance into vault"); + // Compute headroom against the optional vault-balance ceiling. When set, + // we only deposit enough to bring the vault up to `maxCollateralAmount`; + // anything beyond that stays in the wallet. + const max = this.cfg.maxCollateralAmount; + let amount = this.walletTokenBalance; + if (max !== undefined) { + const headroom = max > this.vaultBalance ? max - this.vaultBalance : 0n; + if (headroom === 0n) return; + if (amount > headroom) amount = headroom; + } + this.logger.info( + { + amount: amount.toString(), + wallet: this.walletTokenBalance.toString(), + vault: this.vaultBalance.toString(), + max: max?.toString(), + }, + "depositing wallet balance into vault", + ); await this.account.deposit(amount); await this.update(); } @@ -101,8 +127,6 @@ export class CollateralTracker { this.collateralToken = s.collateralToken; this.utilization = - this.vaultBalance > 0n - ? new Fraction(this.portfolioMM, this.vaultBalance) - : new Fraction(0n); + this.vaultBalance > 0n ? new Fraction(this.portfolioMM, this.vaultBalance) : new Fraction(0n); } } diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index 45f741b..ca30975 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -5,6 +5,24 @@ import { type StringOptions, type TUnsafe, type TSchema, Type } from "@sinclair/ import Ajv from "ajv"; import addFormats from "ajv-formats"; import { ConfigError } from "../errors.ts"; +import { parseUsd, secondsToMs } from "./units.ts"; + +/** USDC base unit decimals — every USD-denominated config field uses this. */ +export const USD_DECIMALS = 6; + +/** Schema fragment that accepts a decimal string or number for a USD value. */ +const TypeUsdAmount = (opts?: { default?: string | number; description?: string }) => + Type.Union( + [Type.String({ pattern: "^-?\\d+(\\.\\d+)?$" }), Type.Number()], + opts as Record | undefined, + ); + +/** Schema fragment that accepts a non-negative seconds value (string or number). */ +const TypeSeconds = (opts?: { minimum?: number; default?: string | number; description?: string }) => + Type.Union( + [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum: opts?.minimum })], + opts as Record | undefined, + ); /** * Shared config schema fragments used by per-app config modules. @@ -28,74 +46,160 @@ const Closed = { additionalProperties: false }; export const walletSchema = Type.Object( { - privateKey: TypeHex(), + privateKey: TypeHex({ description: "Hex-encoded ECDSA private key for the signer." }), }, - Closed, + { ...Closed, description: "Named signer wallet. Referenced by venue.wallet." }, ); export const networkSchema = Type.Object( { - name: Type.String(), - rpcUrl: Type.String(), + name: Type.String({ + description: "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", + }), + rpcUrl: Type.String({ description: "JSON-RPC endpoint URL for reads and tx submission." }), // ethPriceFeed accepts an empty string for "absent" so the YAML // `${ETH_PRICE_FEED_ADDRESS:-}` pattern works without a real value. // Adapters treat empty as `undefined`. ethPriceFeed: Type.Optional( - Type.Union([Type.Literal(""), TypeEthAddress()]), + Type.Union([Type.Literal(""), TypeEthAddress()], { + description: + "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", + }), ), }, - Closed, + { ...Closed, description: "Network connection settings." }, ); +// All *Usd fields are decimal USD (e.g. "50" = 50 USDC, "0.5" = 0.5 USDC). +// They get parsed into 6-decimal bigints at load time. Decimal strings +// preserve precision; numeric literals are accepted for convenience but +// avoid them for sub-cent values where float rounding matters. export const riskSchema = Type.Object( { - maxPositionSize: Type.String(), - maxUtilizationPct: Type.Number({ minimum: 0, maximum: 100, default: 80 }), - minCollateralBalance: Type.String(), - maxDailyLossUsd: Type.String(), - maxGasBudgetPerHourUsd: Type.String({ default: "50000000" }), - maxGasBudgetPerDayUsd: Type.String({ default: "500000000" }), - gasSpikeThresholdPct: Type.Number({ default: 200 }), - gasPenaltyBps: Type.Number({ default: 5 }), - urgentRequoteThresholdTicks: Type.Number({ default: 10 }), + maxPositionSize: TypeUsdAmount({ + description: + "USD. Hard cap on |net position notional|. Beyond this, only risk-reducing quotes are placed.", + }), + maxUtilizationPct: Type.Number({ + minimum: 0, + maximum: 100, + default: 80, + description: + "Margin utilization (used IM / vault balance) above which only risk-reducing quotes are placed.", + }), + minCollateralBalance: TypeUsdAmount({ + description: "USD. Operational floor; halts quoting when vault balance falls below this.", + }), + maxDailyLossUsd: TypeUsdAmount({ + description: + "USD. Daily PnL circuit-breaker. Halts quoting when realized loss + gas exceeds this since 00:00 UTC.", + }), + maxGasBudgetPerHourUsd: TypeUsdAmount({ + default: 50, + description: + "USD. Soft throttle: when hourly gas spend exceeds this, requote cooldown triples.", + }), + maxGasBudgetPerDayUsd: TypeUsdAmount({ + default: 500, + description: "USD. Hard halt: stops requoting once daily gas spend exceeds this.", + }), + gasSpikeThresholdPct: Type.Number({ + default: 200, + description: + "Percent of baseline. Quotes pause when current gas price exceeds (baseline × pct/100).", + }), + gasPenaltyBps: Type.Number({ + default: 5, + description: + "Bps to widen spreads by per unit of gas-cost-as-fraction-of-notional (compensates for fill economics).", + }), + urgentRequoteThresholdTicks: Type.Number({ + default: 10, + description: + "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", + }), }, - Closed, + { ...Closed, description: "Risk caps, circuit-breakers, and gas-price guards." }, ); export const gasSchema = Type.Object( { - gasCapMultiplier: Type.Number({ default: 2.0 }), + gasCapMultiplier: Type.Number({ + default: 2.0, + description: + "Multiplier on viem-suggested gas price for the maxFeePerGas cap. Higher = more reliable inclusion at higher cost.", + }), }, - Closed, + { ...Closed, description: "Gas-pricing knobs." }, ); +// `*Sec` fields are seconds (decimal). The loader converts to integer +// milliseconds with up to 3-digit precision. e.g. "0.5" → 500ms, "60" → 60000ms. export const timingSchema = Type.Object( { - pollIntervalMs: Type.Number({ minimum: 100, default: 3000 }), - requoteThresholdTicks: Type.Number({ minimum: 0, default: 2 }), - requoteCooldownMs: Type.Number({ minimum: 0, default: 1000 }), - resyncIntervalMs: Type.Number({ minimum: 1000, default: 60000 }), - /** Tick-spacing between successive quote levels. */ - levelSpacingTicks: Type.Number({ minimum: 1, default: 1 }), + pollIntervalSec: TypeSeconds({ + minimum: 0.1, + default: 3, + description: "Seconds between main-loop iterations (snapshot, quote, execute).", + }), + requoteThresholdTicks: Type.Number({ + minimum: 0, + default: 2, + description: "Tick deviation from current target before a resting order is replaced.", + }), + requoteCooldownSec: TypeSeconds({ + minimum: 0, + default: 1, + description: "Seconds between requote bursts. Tripled when risk is throttled.", + }), + resyncIntervalSec: TypeSeconds({ + minimum: 1, + default: 60, + description: "Seconds between full BookTracker snapshot refetches (event deltas in between).", + }), + levelSpacingTicks: Type.Number({ + minimum: 1, + default: 1, + description: "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", + }), }, - Closed, + { ...Closed, description: "Loop cadences and requote thresholds." }, ); export const collateralSchema = Type.Object( { - /** Auto-deposit any wallet-held collateral into the vault on every update. */ - autoDeposit: Type.Boolean({ default: false }), - /** Skip auto-deposit if walletTokenBalance < this amount. */ - autoDepositMinAmount: Type.String({ default: "0" }), + autoDeposit: Type.Boolean({ + default: false, + description: + "If true, sweeps wallet token balance into the vault on each loop iteration (subject to min/max).", + }), + autoDepositMinAmount: TypeUsdAmount({ + default: 0, + description: + "USD. Trigger threshold: deposit fires only when wallet balance ≥ this. Dust filter to avoid wasting gas on tiny sweeps.", + }), + maxCollateralAmount: Type.Optional( + Type.Union( + [Type.String({ pattern: "^-?\\d+(\\.\\d+)?$" }), Type.Number()], + { + description: + "USD. Optional ceiling on the total vault balance held by this MM. Each auto-deposit brings the vault up to (but not above) this value; the wallet retains anything beyond it. Omit for no ceiling.", + }, + ), + ), }, - Closed, + { ...Closed, description: "Collateral vault behaviour." }, ); export const healthSchema = Type.Object( { - port: Type.Number({ minimum: 0, default: 3001 }), + port: Type.Number({ + minimum: 0, + default: 3001, + description: "TCP port for the /healthz HTTP endpoint.", + }), }, - Closed, + { ...Closed, description: "Health-check HTTP server." }, ); /** @@ -125,19 +229,127 @@ export function expandEnv(value: unknown, env: NodeJS.ProcessEnv): unknown { return value; } -export interface LoadConfigOpts { +/** + * Parsed shapes of the shared sub-configs after unit conversion. + * The schemas accept user-friendly inputs (decimal USD strings, seconds); + * the loader transforms those into the bigint/ms forms core code consumes. + */ +export interface ParsedRiskConfig { + maxPositionSize: bigint; + maxUtilizationPct: number; + minCollateralBalance: bigint; + maxDailyLossUsd: bigint; + maxGasBudgetPerHourUsd: bigint; + maxGasBudgetPerDayUsd: bigint; + gasSpikeThresholdPct: number; + gasPenaltyBps: number; + urgentRequoteThresholdTicks: number; +} + +export interface ParsedTimingConfig { + pollIntervalMs: number; + requoteThresholdTicks: number; + requoteCooldownMs: number; + resyncIntervalMs: number; + levelSpacingTicks: number; +} + +export interface ParsedCollateralConfig { + autoDeposit: boolean; + autoDepositMinAmount: bigint; + /** + * Optional ceiling on the total vault balance. Each top-up deposits at most + * `max(0, maxCollateralAmount − vaultBalance)`. Undefined → no ceiling. + */ + maxCollateralAmount?: bigint; +} + +interface RawRisk { + maxPositionSize: string | number; + maxUtilizationPct: number; + minCollateralBalance: string | number; + maxDailyLossUsd: string | number; + maxGasBudgetPerHourUsd: string | number; + maxGasBudgetPerDayUsd: string | number; + gasSpikeThresholdPct: number; + gasPenaltyBps: number; + urgentRequoteThresholdTicks: number; +} +interface RawTiming { + pollIntervalSec: string | number; + requoteThresholdTicks: number; + requoteCooldownSec: string | number; + resyncIntervalSec: string | number; + levelSpacingTicks: number; +} +interface RawCollateral { + autoDeposit: boolean; + autoDepositMinAmount: string | number; + maxCollateralAmount?: string | number; +} + +export function parseRiskConfig(raw: RawRisk): ParsedRiskConfig { + return { + maxPositionSize: parseUsd(raw.maxPositionSize, USD_DECIMALS, "risk.maxPositionSize"), + maxUtilizationPct: raw.maxUtilizationPct, + minCollateralBalance: parseUsd(raw.minCollateralBalance, USD_DECIMALS, "risk.minCollateralBalance"), + maxDailyLossUsd: parseUsd(raw.maxDailyLossUsd, USD_DECIMALS, "risk.maxDailyLossUsd"), + maxGasBudgetPerHourUsd: parseUsd( + raw.maxGasBudgetPerHourUsd, + USD_DECIMALS, + "risk.maxGasBudgetPerHourUsd", + ), + maxGasBudgetPerDayUsd: parseUsd( + raw.maxGasBudgetPerDayUsd, + USD_DECIMALS, + "risk.maxGasBudgetPerDayUsd", + ), + gasSpikeThresholdPct: raw.gasSpikeThresholdPct, + gasPenaltyBps: raw.gasPenaltyBps, + urgentRequoteThresholdTicks: raw.urgentRequoteThresholdTicks, + }; +} + +export function parseTimingConfig(raw: RawTiming): ParsedTimingConfig { + return { + pollIntervalMs: secondsToMs(raw.pollIntervalSec, "timing.pollIntervalSec"), + requoteThresholdTicks: raw.requoteThresholdTicks, + requoteCooldownMs: secondsToMs(raw.requoteCooldownSec, "timing.requoteCooldownSec"), + resyncIntervalMs: secondsToMs(raw.resyncIntervalSec, "timing.resyncIntervalSec"), + levelSpacingTicks: raw.levelSpacingTicks, + }; +} + +export function parseCollateralConfig(raw: RawCollateral): ParsedCollateralConfig { + return { + autoDeposit: raw.autoDeposit, + autoDepositMinAmount: parseUsd( + raw.autoDepositMinAmount, + USD_DECIMALS, + "collateral.autoDepositMinAmount", + ), + maxCollateralAmount: + raw.maxCollateralAmount !== undefined + ? parseUsd(raw.maxCollateralAmount, USD_DECIMALS, "collateral.maxCollateralAmount") + : undefined, + }; +} + +export interface LoadConfigOpts { schema: TSchema; path?: string; env?: NodeJS.ProcessEnv; - /** App-specific cross-field validation; throws ConfigError on failure. */ - validate?: (cfg: T) => void; + /** Transform the AJV-validated raw object into the typed parsed config. */ + parse: (raw: TRaw) => TParsed; + /** App-specific cross-field validation on the parsed config. */ + validate?: (cfg: TParsed) => void; } -export function loadConfigFromFile(opts: LoadConfigOpts): T { +export function loadConfigFromFile( + opts: LoadConfigOpts, +): TParsed { const env = opts.env ?? process.env; // Precedence: explicit opts.path > --config CLI arg > MAKER_CONFIG env var. - // CLI arg deliberately beats env so docker/CI can pass a different path - // without unsetting the inherited env. const configPath = opts.path ?? parseConfigArg(process.argv) ?? env.MAKER_CONFIG; if (!configPath) { throw new ConfigError( @@ -167,7 +379,7 @@ export function loadConfigFromFile(opts: LoadConfigOpts): T { throw new ConfigError(`Config validation failed: ${msgs}`); } - const cfg = expanded as T; + const cfg = opts.parse(expanded as TRaw); opts.validate?.(cfg); return cfg; } diff --git a/market-maker/src/core/config/units.ts b/market-maker/src/core/config/units.ts new file mode 100644 index 0000000..a1b7327 --- /dev/null +++ b/market-maker/src/core/config/units.ts @@ -0,0 +1,72 @@ +// Decimal-string parsers for human-friendly config values. +// +// All parsing happens via integer string manipulation, never floating +// point, so values like "0.000001" survive without precision loss. The +// schemas accept `string | number`; numbers are stringified first via +// the canonical decimal form and then parsed exactly. + +import { ConfigError } from "../errors.ts"; + +const DECIMAL_RE = /^-?\d+(\.\d+)?$/; +const SECONDS_RE = /^\d+(\.\d+)?$/; + +function toDecimalString(input: unknown, field: string): string { + if (typeof input === "string") return input.trim(); + if (typeof input === "number") { + if (!Number.isFinite(input)) { + throw new ConfigError(`${field}: non-finite number`); + } + // toString avoids exponent notation for typical magnitudes; for very + // small/large floats users should pass a string anyway. + const str = input.toString(); + if (str.includes("e") || str.includes("E")) { + throw new ConfigError( + `${field}: numeric literal "${str}" uses exponent notation; pass as a string instead`, + ); + } + return str; + } + throw new ConfigError(`${field}: expected string or number, got ${typeof input}`); +} + +/** + * Parse a USD-denominated decimal value into a bigint with the given + * `decimals` (6 for USDC). "50" → 50_000_000n, "0.5" → 500_000n, "50.123456" + * → 50_123_456n. More than `decimals` fractional digits is rejected so the + * caller can't silently round away precision. + */ +export function parseUsd(input: unknown, decimals: number, field: string): bigint { + const str = toDecimalString(input, field); + if (!DECIMAL_RE.test(str)) { + throw new ConfigError(`${field}: invalid decimal "${str}"`); + } + const negative = str.startsWith("-"); + const body = negative ? str.slice(1) : str; + const [intPart, fracPart = ""] = body.split("."); + if (fracPart.length > decimals) { + throw new ConfigError( + `${field}: too many fractional digits (max ${decimals}) in "${str}"`, + ); + } + const padded = (fracPart + "0".repeat(decimals)).slice(0, decimals); + const result = BigInt(intPart) * 10n ** BigInt(decimals) + BigInt(padded); + return negative ? -result : result; +} + +/** + * Convert a non-negative seconds value (string or number, decimals allowed) + * into integer milliseconds. "3" → 3000, "0.5" → 500, "0.001" → 1. More than + * 3 fractional digits (sub-millisecond) is rejected. + */ +export function secondsToMs(input: unknown, field: string): number { + const str = toDecimalString(input, field); + if (!SECONDS_RE.test(str)) { + throw new ConfigError(`${field}: invalid non-negative seconds "${str}"`); + } + const [intPart, fracPart = ""] = str.split("."); + if (fracPart.length > 3) { + throw new ConfigError(`${field}: sub-millisecond precision not supported in "${str}"`); + } + const padded = (fracPart + "000").slice(0, 3); + return Number(intPart) * 1000 + Number(padded); +} diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts new file mode 100644 index 0000000..f3743b3 --- /dev/null +++ b/market-maker/src/core/rawOracle.ts @@ -0,0 +1,93 @@ +/** + * Shared "raw oracle" helper. + * + * Both venue contracts (`Futures.getMarketPrice`, `HashPowerPerpsDEX.getMarketPrice`) + * pre-round the oracle answer to the nearest tick before returning it. That + * collapses the MM's reservation price onto a tick boundary, which forces a + * 2-tick floor on the symmetric bid/ask layout. + * + * `RawOracleReader` reads the underlying Chainlink aggregator directly and + * applies the same `10^(oracle.decimals − token.decimals)` rebase the contract + * does, but skips the tick rounding. The MM gets a unit-precision mid that + * lands between ticks ~99% of the time, so `roundDownToTick(r) → bidMid` and + * `roundUpToTick(r) → askMid` produce a 1-tick spread without any extra + * pricing-strategy plumbing. + * + * The two venues differ only in *how* the (oracle address, scaling divisor) + * pair is discovered. Each adapter supplies that as a `resolve()` callback; + * the reader caches the result for the lifetime of the process (both values + * change only on `setOracle`-style admin txs). + */ + +import type { PublicClient } from "viem"; + +/** Chainlink AggregatorV3Interface — read-only slice we need for the raw mid. */ +export const chainlinkAggregatorAbi = [ + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "latestRoundData", + outputs: [ + { internalType: "uint80", name: "roundId", type: "uint80" }, + { internalType: "int256", name: "answer", type: "int256" }, + { internalType: "uint256", name: "startedAt", type: "uint256" }, + { internalType: "uint256", name: "updatedAt", type: "uint256" }, + { internalType: "uint80", name: "answeredInRound", type: "uint80" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +export interface RawOracleConfig { + oracle: `0x${string}`; + /** 10^(oracle.decimals − token.decimals); used to rebase the answer to token decimals. */ + divisor: bigint; +} + +export class RawOracleReader { + private readonly publicClient: PublicClient; + private readonly resolve: () => Promise; + private readonly label: string; + private cache: RawOracleConfig | null = null; + + constructor(opts: { + publicClient: PublicClient; + /** Discover (oracle, divisor) on first read; called at most once unless reset. */ + resolve: () => Promise; + /** Used in error messages, e.g. "futures" / "perps". */ + label: string; + }) { + this.publicClient = opts.publicClient; + this.resolve = opts.resolve; + this.label = opts.label; + } + + /** Latest oracle answer, rebased to token decimals (no tick rounding). */ + async read(): Promise { + if (this.cache === null) { + this.cache = await this.resolve(); + } + const data = await this.publicClient.readContract({ + address: this.cache.oracle, + abi: chainlinkAggregatorAbi, + functionName: "latestRoundData", + }); + const answer = data[1]; + if (answer <= 0n) { + throw new Error(`${this.label}: oracle returned non-positive answer (${answer.toString()})`); + } + return answer / this.cache.divisor; + } + + /** Drop cached (oracle, divisor) — next `read()` will re-resolve. */ + invalidate(): void { + this.cache = null; + } +} diff --git a/market-maker/src/core/runner.ts b/market-maker/src/core/runner.ts index c9bafe5..001b0c3 100644 --- a/market-maker/src/core/runner.ts +++ b/market-maker/src/core/runner.ts @@ -18,6 +18,12 @@ const MAX_ERROR_DELAY_MS = 3 * 60_000; export interface RunnerOpts { pollIntervalMs: number; + /** + * If true (default), SIGINT/SIGTERM trigger `executor.cancelAll()` before + * exit. Set false to leave resting orders on the book — handy for fast + * restarts where you don't want to pay cancel-then-reopen gas. + */ + cancelOrdersOnShutdown?: boolean; instrument: InstrumentAdapter; oracle: OracleTracker; gas: GasTracker; @@ -49,6 +55,7 @@ export interface RunnerOpts { */ export async function runMakerLoop(opts: RunnerOpts): Promise { const { pollIntervalMs, instrument, oracle, gas, book, inventory, collateral, risk, quoter, executor, health, logger } = opts; + const cancelOrdersOnShutdown = opts.cancelOrdersOnShutdown ?? true; const mmAddress = instrument.venue.wallet.account.address; health.executorStats = executor.stats; @@ -98,11 +105,15 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; - logger.info("shutting down…"); - try { - await executor.cancelAll(); - } catch (err) { - logger.error({ err }, "failed to cancel orders during shutdown"); + logger.info({ cancelOrdersOnShutdown }, "shutting down…"); + if (cancelOrdersOnShutdown) { + try { + await executor.cancelAll(); + } catch (err) { + logger.error({ err }, "failed to cancel orders during shutdown"); + } + } else { + logger.info("cancelOrdersOnShutdown=false; leaving resting orders on the book"); } book.stop(); await health.stop(); diff --git a/market-maker/src/core/vaultDeposit.ts b/market-maker/src/core/vaultDeposit.ts index 49c0089..b72cf57 100644 --- a/market-maker/src/core/vaultDeposit.ts +++ b/market-maker/src/core/vaultDeposit.ts @@ -134,7 +134,16 @@ export async function depositToVault(opts: DepositToVaultOpts): Promise { } async function tryPermitDeposit(opts: DepositToVaultOpts): Promise { - const { publicClient, walletClient, account, chain, vaultAddress, collateralToken, amount, logger } = opts; + const { + publicClient, + walletClient, + account, + chain, + vaultAddress, + collateralToken, + amount, + logger, + } = opts; // Discover permit domain. If nonces() reverts, the token doesn't implement // EIP-2612 — bail out cleanly. @@ -158,7 +167,13 @@ async function tryPermitDeposit(opts: DepositToVaultOpts): Promise { let domain: { name: string; version: string; chainId: number; verifyingContract: `0x${string}` }; if (domainResult.status === "success") { const [, dName, dVersion, dChainId, dVerifyingContract] = domainResult.result; - domain = { name: dName, version: dVersion, chainId: Number(dChainId), verifyingContract: dVerifyingContract }; + domain = { + name: dName, + version: dVersion, + chainId: Number(dChainId), + verifyingContract: dVerifyingContract, + }; + console.log("domain", domain); } else { if (nameResult.status === "failure") { logger.warn({ err: nameResult.error }, "token has no name(); using approve fallback"); @@ -188,6 +203,7 @@ async function tryPermitDeposit(opts: DepositToVaultOpts): Promise { const v = Number.parseInt(signature.slice(130, 132), 16); try { + console.log("args", [owner, amount, deadline, v, r, s]); const hash = await walletClient.writeContract({ address: vaultAddress, abi: vaultAbi, @@ -206,7 +222,16 @@ async function tryPermitDeposit(opts: DepositToVaultOpts): Promise { } async function approveAndDeposit(opts: DepositToVaultOpts): Promise { - const { publicClient, walletClient, account, chain, vaultAddress, collateralToken, amount, logger } = opts; + const { + publicClient, + walletClient, + account, + chain, + vaultAddress, + collateralToken, + amount, + logger, + } = opts; logger.info({ amount: amount.toString() }, "approving vault to spend collateral"); const approveHash = await walletClient.writeContract({ diff --git a/market-maker/tests-pending/config.test.ts b/market-maker/tests-pending/config.test.ts index cdf891d..1fe6553 100644 --- a/market-maker/tests-pending/config.test.ts +++ b/market-maker/tests-pending/config.test.ts @@ -4,7 +4,7 @@ import { loadConfig } from "../src/config.ts"; const REQUIRED_ENV = { NETWORK: "hardhat", - ETH_NODE_ADDRESS: "http://localhost:8545", + ETHEREUM_RPC_URL: "http://localhost:8545", PERPS_ADDRESS: "0x0000000000000000000000000000000000000001", MAKER_PRIVATE_KEY: "0x0000000000000000000000000000000000000000000000000000000000000001", }; diff --git a/market-maker/tests-pending/market-maker.process.test.ts b/market-maker/tests-pending/market-maker.process.test.ts index ab5d4fd..9297fb8 100644 --- a/market-maker/tests-pending/market-maker.process.test.ts +++ b/market-maker/tests-pending/market-maker.process.test.ts @@ -53,7 +53,7 @@ function spawnMM(port: number): MakerProcess { env: { ...process.env, NETWORK: "hardhat", - ETH_NODE_ADDRESS: "http://127.0.0.1:8545", + ETHEREUM_RPC_URL: "http://127.0.0.1:8545", PERPS_ADDRESS: deployment.contracts.perpsAddress, MAKER_PRIVATE_KEY: MM_ACCOUNT.privateKey, MAKER_HEALTH_PORT: String(port), diff --git a/market-maker/tests/apps/futures/config.test.ts b/market-maker/tests/apps/futures/config.test.ts index 19fd8ea..b8b13e0 100644 --- a/market-maker/tests/apps/futures/config.test.ts +++ b/market-maker/tests/apps/futures/config.test.ts @@ -25,7 +25,7 @@ venue: pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 + marginCallTimeSec: 3600 minSpreadBps: 15 volatilityMultiplier: 2.5 maxSkewTicks: 0 @@ -35,10 +35,10 @@ sizing: numLevelsPerSide: 4 taperRatio: 0.6 risk: - maxPositionSize: "50000000" + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" - maxDailyLossUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 gas: gasCapMultiplier: 2.0 timing: {} @@ -72,7 +72,7 @@ describe("loadFuturesConfig", () => { `pricing: strategy: reservation-price riskAversion: 0.001 - marginCallTimeSeconds: 3600 + marginCallTimeSec: 3600 minSpreadBps: 15 volatilityMultiplier: 2.5 maxSkewTicks: 0`, @@ -109,8 +109,8 @@ describe("loadFuturesConfig", () => { assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); }); - it("requires riskAversion and marginCallTimeSeconds", () => { - const yaml = VALID_YAML.replace(" riskAversion: 0.001\n marginCallTimeSeconds: 3600\n", ""); + it("requires riskAversion and marginCallTimeSec", () => { + const yaml = VALID_YAML.replace(" riskAversion: 0.001\n marginCallTimeSec: 3600\n", ""); const path = writeTmp(tmpDir, "test.yml", yaml); assert.throws(() => loadFuturesConfig({ path }), /Config validation failed/); }); diff --git a/market-maker/tests/apps/futures/main.smoke.test.ts b/market-maker/tests/apps/futures/main.smoke.test.ts index 8e55ce5..4beb242 100644 --- a/market-maker/tests/apps/futures/main.smoke.test.ts +++ b/market-maker/tests/apps/futures/main.smoke.test.ts @@ -15,9 +15,8 @@ describe("futures app config smoke", () => { const path = resolve(import.meta.dirname, `../../../configs/futures.${e}.yml`); const env: NodeJS.ProcessEnv = { PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - ETH_NODE_ADDRESS: "https://example.com/rpc", + ALCHEMY_API_KEY: "stub-alchemy-key", FUTURES_ADDRESS: "0x1234567890123456789012345678901234567890", - NETWORK: "arbitrum", }; const cfg = loadFuturesConfig({ path, env }); assert.equal(cfg.venue.kind, "futures"); diff --git a/market-maker/tests/apps/perps/config.test.ts b/market-maker/tests/apps/perps/config.test.ts index 95b1646..ada0e4e 100644 --- a/market-maker/tests/apps/perps/config.test.ts +++ b/market-maker/tests/apps/perps/config.test.ts @@ -33,14 +33,14 @@ sizing: baseQuantity: "1000000" numLevelsPerSide: 5 risk: - maxPositionSize: "50000000" + maxPositionSize: 50 maxUtilizationPct: 80 - minCollateralBalance: "10000000" - maxDailyLossUsd: "500000000" + minCollateralBalance: 10 + maxDailyLossUsd: 500 gas: gasCapMultiplier: 2.0 timing: - pollIntervalMs: 3000 + pollIntervalSec: 3 collateral: {} health: port: 8080 diff --git a/market-maker/tests/apps/perps/main.smoke.test.ts b/market-maker/tests/apps/perps/main.smoke.test.ts index ad135ba..035fb15 100644 --- a/market-maker/tests/apps/perps/main.smoke.test.ts +++ b/market-maker/tests/apps/perps/main.smoke.test.ts @@ -15,9 +15,8 @@ describe("perps app config smoke", () => { const path = resolve(import.meta.dirname, `../../../configs/perps.${e}.yml`); const env: NodeJS.ProcessEnv = { PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - ETH_NODE_ADDRESS: "https://example.com/rpc", + ALCHEMY_API_KEY: "stub-alchemy-key", PERPS_ADDRESS: "0x1234567890123456789012345678901234567890", - NETWORK: "arbitrum", }; const cfg = loadPerpsConfig({ path, env }); assert.equal(cfg.venue.kind, "perps"); diff --git a/market-maker/tests/core/collateralTracker.test.ts b/market-maker/tests/core/collateralTracker.test.ts new file mode 100644 index 0000000..07b3bc2 --- /dev/null +++ b/market-maker/tests/core/collateralTracker.test.ts @@ -0,0 +1,128 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import { CollateralTracker } from "../../src/core/collateralTracker.ts"; +import type { CollateralAccount, CollateralSnapshot } from "../../src/core/adapter.ts"; + +const logger = pino({ level: "silent" }); + +function makeAccount(initial: Partial): { + account: CollateralAccount; + deposits: bigint[]; + setBalance: (b: bigint) => void; +} { + let snap: CollateralSnapshot = { + vaultBalance: 0n, + portfolioIM: 0n, + portfolioMM: 0n, + venueOrderMargin: 0n, + venueUnrealizedPnl: 0n, + walletTokenBalance: 0n, + nativeBalance: 0n, + collateralToken: "0x0000000000000000000000000000000000000001", + ...initial, + }; + const deposits: bigint[] = []; + const account: CollateralAccount = { + snapshot: async () => snap, + imSpotShock: async () => 0n, + deposit: async (amount) => { + deposits.push(amount); + snap = { ...snap, walletTokenBalance: snap.walletTokenBalance - amount, vaultBalance: snap.vaultBalance + amount }; + }, + canPlace: async () => true, + }; + return { account, deposits, setBalance: (b) => { snap = { ...snap, walletTokenBalance: b }; } }; +} + +describe("CollateralTracker.maybeTopUp", () => { + let env: ReturnType; + beforeEach(() => { + env = makeAccount({}); + }); + + it("does nothing when autoDeposit is disabled", async () => { + env.setBalance(100_000_000n); + const t = new CollateralTracker(env.account, { autoDeposit: false, autoDepositMinAmount: 0n }, logger); + await t.update(); + await t.maybeTopUp(); + assert.deepStrictEqual(env.deposits, []); + }); + + it("skips deposit when balance is below minAmount (dust filter)", async () => { + env.setBalance(500_000n); // 0.5 USDC + const t = new CollateralTracker( + env.account, + { autoDeposit: true, autoDepositMinAmount: 1_000_000n }, // 1 USDC + logger, + ); + await t.update(); + await t.maybeTopUp(); + assert.deepStrictEqual(env.deposits, []); + }); + + it("sweeps the full wallet balance when threshold is met and no max", async () => { + env.setBalance(50_000_000n); // 50 USDC + const t = new CollateralTracker( + env.account, + { autoDeposit: true, autoDepositMinAmount: 1_000_000n }, + logger, + ); + await t.update(); + await t.maybeTopUp(); + assert.deepStrictEqual(env.deposits, [50_000_000n]); + }); + + it("caps deposit so the vault balance does not exceed maxCollateralAmount", async () => { + env = makeAccount({ vaultBalance: 30_000_000n }); // 30 USDC already in vault + env.setBalance(500_000_000n); // 500 USDC in wallet + const t = new CollateralTracker( + env.account, + { + autoDeposit: true, + autoDepositMinAmount: 1_000_000n, + maxCollateralAmount: 100_000_000n, // ceiling: 100 USDC total in vault + }, + logger, + ); + await t.update(); + await t.maybeTopUp(); + // headroom = 100 − 30 = 70 USDC + assert.deepStrictEqual(env.deposits, [70_000_000n]); + }); + + it("deposits full wallet balance when vault is well below maxCollateralAmount", async () => { + env = makeAccount({ vaultBalance: 10_000_000n }); // 10 USDC in vault + env.setBalance(40_000_000n); // 40 USDC in wallet + const t = new CollateralTracker( + env.account, + { + autoDeposit: true, + autoDepositMinAmount: 1_000_000n, + maxCollateralAmount: 100_000_000n, + }, + logger, + ); + await t.update(); + await t.maybeTopUp(); + // headroom = 90 USDC, wallet = 40 USDC → deposit full wallet + assert.deepStrictEqual(env.deposits, [40_000_000n]); + }); + + it("skips deposit when vault is already at or above maxCollateralAmount", async () => { + env = makeAccount({ vaultBalance: 100_000_000n }); // already at ceiling + env.setBalance(50_000_000n); + const t = new CollateralTracker( + env.account, + { + autoDeposit: true, + autoDepositMinAmount: 1_000_000n, + maxCollateralAmount: 100_000_000n, + }, + logger, + ); + await t.update(); + await t.maybeTopUp(); + assert.deepStrictEqual(env.deposits, []); + }); +}); diff --git a/market-maker/tests/core/config/units.test.ts b/market-maker/tests/core/config/units.test.ts new file mode 100644 index 0000000..2d31b8f --- /dev/null +++ b/market-maker/tests/core/config/units.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseUsd, secondsToMs } from "../../../src/core/config/units.ts"; + +describe("parseUsd", () => { + it("converts integer USD to 6-decimal bigint", () => { + assert.strictEqual(parseUsd("50", 6, "x"), 50_000_000n); + assert.strictEqual(parseUsd(50, 6, "x"), 50_000_000n); + assert.strictEqual(parseUsd(0, 6, "x"), 0n); + }); + + it("converts decimal USD without precision loss", () => { + assert.strictEqual(parseUsd("0.5", 6, "x"), 500_000n); + assert.strictEqual(parseUsd("0.000001", 6, "x"), 1n); + assert.strictEqual(parseUsd("123.456789", 6, "x"), 123_456_789n); + }); + + it("supports negative values", () => { + assert.strictEqual(parseUsd("-50", 6, "x"), -50_000_000n); + assert.strictEqual(parseUsd("-0.5", 6, "x"), -500_000n); + }); + + it("rejects more fractional digits than `decimals`", () => { + assert.throws(() => parseUsd("0.0000001", 6, "x"), /too many fractional digits/); + }); + + it("rejects malformed input", () => { + assert.throws(() => parseUsd("abc", 6, "x"), /invalid decimal/); + assert.throws(() => parseUsd("1.2.3", 6, "x"), /invalid decimal/); + }); + + it("rejects exponent notation", () => { + assert.throws(() => parseUsd(1e-7, 6, "x"), /exponent notation/); + }); + + it("rejects non-finite numbers", () => { + assert.throws(() => parseUsd(Number.POSITIVE_INFINITY, 6, "x"), /non-finite/); + assert.throws(() => parseUsd(Number.NaN, 6, "x"), /non-finite/); + }); +}); + +describe("secondsToMs", () => { + it("converts integer seconds", () => { + assert.strictEqual(secondsToMs("3", "x"), 3000); + assert.strictEqual(secondsToMs(60, "x"), 60_000); + assert.strictEqual(secondsToMs(0, "x"), 0); + }); + + it("converts fractional seconds without float drift", () => { + assert.strictEqual(secondsToMs("0.1", "x"), 100); + assert.strictEqual(secondsToMs("0.001", "x"), 1); + assert.strictEqual(secondsToMs("1.5", "x"), 1500); + }); + + it("rejects sub-millisecond precision", () => { + assert.throws(() => secondsToMs("0.0001", "x"), /sub-millisecond/); + }); + + it("rejects negative values", () => { + assert.throws(() => secondsToMs("-1", "x"), /invalid non-negative seconds/); + }); +}); From fa0012298cede2445595357ad0c35b51bfe62475 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 19:09:49 +0200 Subject: [PATCH 007/180] fix: readme --- {market-maker => .github}/infra/README.md | 0 {market-maker => .github}/infra/ecs-task.tf | 0 .gitignore | 3 +- market-maker/README.md | 333 +- market-maker/package.json | 5 +- market-maker/pnpm-lock.yaml | 5629 ++++++++++++++++- market-maker/scripts/sync-abi.sh | 36 - market-maker/src/abi/CollateralVault.ts | 940 --- market-maker/src/abi/Futures.ts | 1545 ----- market-maker/src/abi/FuturesMmExtensions.ts | 70 - market-maker/src/abi/HashPowerPerpsDEX.ts | 1474 ----- market-maker/src/abi/Multicall3.ts | 440 -- market-maker/src/abi/PortfolioMarginEngine.ts | 588 -- market-maker/src/adapters/futures/events.ts | 2 +- .../src/adapters/futures/instrument.ts | 13 +- .../src/adapters/futures/ownOrders.ts | 5 +- market-maker/src/adapters/futures/venue.ts | 8 +- market-maker/src/adapters/perps/events.ts | 2 +- market-maker/src/adapters/perps/instrument.ts | 2 +- market-maker/src/adapters/perps/venue.ts | 8 +- 20 files changed, 5590 insertions(+), 5513 deletions(-) rename {market-maker => .github}/infra/README.md (100%) rename {market-maker => .github}/infra/ecs-task.tf (100%) delete mode 100755 market-maker/scripts/sync-abi.sh delete mode 100644 market-maker/src/abi/CollateralVault.ts delete mode 100644 market-maker/src/abi/Futures.ts delete mode 100644 market-maker/src/abi/FuturesMmExtensions.ts delete mode 100644 market-maker/src/abi/HashPowerPerpsDEX.ts delete mode 100644 market-maker/src/abi/Multicall3.ts delete mode 100644 market-maker/src/abi/PortfolioMarginEngine.ts diff --git a/market-maker/infra/README.md b/.github/infra/README.md similarity index 100% rename from market-maker/infra/README.md rename to .github/infra/README.md diff --git a/market-maker/infra/ecs-task.tf b/.github/infra/ecs-task.tf similarity index 100% rename from market-maker/infra/ecs-task.tf rename to .github/infra/ecs-task.tf diff --git a/.gitignore b/.gitignore index 48e0213..f913390 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .env .env.* -!.env.example \ No newline at end of file +!.env.example +.DS_Store \ No newline at end of file diff --git a/market-maker/README.md b/market-maker/README.md index 5e38c6e..426dbe5 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -1,21 +1,35 @@ -# Perps Market Maker +# Market Maker -Automated market maker for the HashPowerPerpsDEX on-chain CLOB. Provides two-sided liquidity by placing layered limit orders around the oracle price, dynamically adjusting quotes based on inventory, volatility, and gas conditions. +Automated market maker for the Titan derivatives stack. Provides two-sided +liquidity on the **HashPowerPerpsDEX** (perps) and **Futures** (dated) +order books by placing layered limit quotes around the oracle price and +dynamically adjusting them based on inventory, volatility, and gas +conditions. + +The same codebase ships two independent processes — one per venue — each +with its own wallet, configuration, and health port. Both share a +`core/` library for pricing, sizing, risk, execution, and health +reporting; venue-specific logic lives behind a thin `InstrumentAdapter` +interface in `src/adapters/{perps,futures}/`. ## Architecture -The bot runs a single poll loop (`tick`) that reads on-chain state, computes desired quotes, and reconciles them against resting orders. +A single poll loop (`tick`) reads on-chain state, computes desired +quotes, and reconciles them against resting orders. ```mermaid graph LR subgraph On-chain - CLOB[HashPowerPerpsDEX CLOB] + V[Venue
Perps DEX or Futures] + Vault[CollateralVault] + Engine[PortfolioMarginEngine] end subgraph State readers OT[OracleTracker] GT[GasTracker] BT[BookTracker] + CT[CollateralTracker] IM[InventoryManager] end @@ -23,12 +37,14 @@ graph LR GT -- gas price, spike % --> Q IM -- skew, utilization --> Q GT -- gas budget --> RM[RiskManager] - IM -- collateral, position --> RM + CT -- collateral, IM/MM --> RM RM -- allowed sides, halt --> Q Q -- desired bids & asks --> OE[OrderExecutor] - OE -- cancel / place --> CLOB + OE -- cancel / place --> V BT -- own orders --> OE - CLOB -. events .-> BT + V -. events .-> BT + CT -. balanceOf, IM/MM .-> Vault + CT -. canPlaceOrder .-> Engine OE -- gas cost --> RM OE -- stats --> HC[HealthCheck] @@ -39,67 +55,101 @@ graph LR | Component | File | Role | |---|---|---| -| **OracleTracker** | `oracleTracker.ts` | Reads `getMarketPrice()` each tick; tracks rolling volatility | -| **GasTracker** | `gasTracker.ts` | Reads gas price, detects spikes, estimates tx costs in USD via ETH price feed | -| **BookTracker** | `bookTracker.ts` | Maintains local mirror of order book via `getOrderBookPrices()` + event watching; tracks own orders | -| **InventoryManager** | `inventoryManager.ts` | Reads `getUserPosition()`, `balanceOf()`, `getMaintenanceMargin()` to track net exposure and utilization | -| **RiskManager** | `riskManager.ts` | Drawdown circuit breaker, daily loss limit, gas budget throttling, position limit enforcement | -| **Quoter** | `quoter.ts` | Computes bid/ask levels: Avellaneda-Stoikov inspired spreads with gas floor, volatility scaling, inventory skew | -| **OrderExecutor** | `orderExecutor.ts` | Diffs desired quotes vs resting orders; cancels stale, places new; gas-capped transactions | -| **HealthCheck** | `healthcheck.ts` | HTTP `/health` endpoint exposing live operational metrics | +| **OracleTracker** | `core/oracleTracker.ts` | Reads the venue's raw oracle price each tick; tracks rolling volatility | +| **GasTracker** | `core/gasTracker.ts` | Reads gas price, detects spikes, estimates tx costs in USD via ETH price feed | +| **BookTracker** | `core/bookTracker.ts` | Maintains a local mirror of the venue's book + own orders via venue-supplied snapshots and event subscriptions | +| **CollateralTracker** | `core/collateralTracker.ts` | Reads vault balance, portfolio IM/MM from `PortfolioMarginEngine`, manages auto-deposits | +| **InventoryManager** | `core/inventoryManager.ts` | Tracks net position from venue-reported state | +| **RiskManager** | `core/riskManager.ts` | Drawdown circuit breaker, daily loss limit, gas budget throttling, position limit enforcement, engine `canPlaceOrder` checks | +| **Quoter** | `core/quoter.ts` | Computes bid/ask levels: Avellaneda-Stoikov inspired spreads with gas floor, volatility scaling, inventory skew | +| **OrderExecutor** | `core/orderExecutor.ts` | Diffs desired quotes vs resting orders; cancels stale, places new; gas-capped transactions | +| **HealthCheck** | `core/healthcheck.ts` | HTTP `/health` endpoint exposing live operational metrics | +| **Adapters** | `adapters/{perps,futures}/` | Venue-specific encoding/decoding, oracle access, snapshot fetching | ### Tick cycle -Each iteration: - -1. **Update** oracle price, gas price, order book, inventory -2. **Risk check** — halt if collateral below minimum or daily loss exceeded; throttle if gas budget exceeded -3. **Compute quotes** — N levels per side, spread = max(minSpreadBps, gasFloor) + volatility + inventory skew + gas penalty -4. **Reconcile** — selective requoting: only cancel/place orders that changed; skips requote if price drift is below threshold or cooldown hasn't elapsed; skips non-urgent requotes during gas spikes +1. **Update** oracle price, gas price, order book, inventory, collateral +2. **Risk check** — halt if collateral below minimum or daily loss + exceeded; throttle if gas budget exceeded +3. **Compute quotes** — N levels per side, spread = max(minSpreadBps, + gasFloor) + volatility + inventory skew + gas penalty +4. **Reconcile** — selective requoting: only cancel/place orders that + changed; skips requote if price drift is below threshold or cooldown + hasn't elapsed; skips non-urgent requotes during gas spikes ### Quoting strategy - **Base spread**: configurable minimum in basis points (`minSpreadBps`) -- **Gas floor**: minimum spread to break even on round-trip gas costs (cancel + place) -- **Volatility component**: `volatilityMultiplier * rollingVolatility * 10000` bps -- **Inventory skew**: shifts both bid and ask toward reducing exposure; controlled by `inventorySkewGamma` and `maxSkewTicks` -- **Gas spike penalty**: widens spread proportionally when gas exceeds median by `gasSpikeThresholdPct` -- **Level sizing**: deeper levels get progressively larger quantities (`baseQuantity * level`) +- **Gas floor**: minimum spread to break even on round-trip gas costs + (cancel + place) +- **Volatility component**: `volatilityMultiplier · rollingVolatility · 10000` bps +- **Inventory skew**: shifts both bid and ask toward reducing exposure; + controlled by `inventorySkewGamma` and `maxSkewTicks` +- **Gas spike penalty**: widens spread proportionally when gas exceeds + median by `gasSpikeThresholdPct` +- **Level sizing**: geometric taper — outer levels are progressively + larger by `levelSizeRatio` ### Risk controls -- **Position limits**: max net position size; blocks the side that would increase exposure -- **Utilization cap**: when `requiredMargin / collateral` exceeds `maxUtilizationPct`, only quotes the reducing side -- **Drawdown halt**: stops quoting and cancels all orders if collateral drops below `minCollateralBalance` -- **Daily loss halt**: includes gas costs in PnL calculation; halts if daily loss exceeds `maxDailyLossUsd` -- **Gas budget throttle**: rolling hourly/daily gas budgets; when exceeded, requote cooldown and threshold increase (3x and 2x) -- **Gas spike deferral**: during gas spikes, requotes are deferred unless price drift exceeds `urgentRequoteThresholdTicks` -- **Gas cap**: `maxFeePerGas` is capped at `gasCapMultiplier * medianGasPrice` +- **Position limits**: max net position size; blocks the side that + would increase exposure +- **Utilization cap**: when `requiredMargin / collateral` exceeds + `maxUtilizationPct`, only quotes the reducing side +- **Drawdown halt**: stops quoting and cancels all orders if + collateral drops below `minCollateralBalance` +- **Daily loss halt**: includes gas costs in PnL calculation; halts if + daily loss exceeds `maxDailyLossUsd` +- **Gas budget throttle**: rolling hourly/daily gas budgets; when + exceeded, requote cooldown and threshold tighten +- **Gas spike deferral**: during gas spikes, requotes are deferred + unless price drift exceeds `urgentRequoteThresholdTicks` +- **Gas cap**: `maxFeePerGas` is capped at `gasCapMultiplier · medianGasPrice` +- **Engine pre-check**: each placement is gated by + `PortfolioMarginEngine.canPlaceOrder(additionalIM)` so we never + submit orders the vault can't margin + +### Matching modes + +- **Perps** (`limit`): contract matches at any price strictly better + than the resting limit. Outdated own orders that are still better + than the new desired price are kept in place. +- **Futures** (`exact`): contract matches at the exact resting price. + Any deviation in either direction means the order has to be + cancelled and re-placed. + +The shared `OrderExecutor` branches on the adapter's `matchingMode` +when deciding whether an existing order is still good. ### Graceful shutdown -On `SIGINT` / `SIGTERM`, the bot cancels all resting orders before exiting. +On `SIGINT` / `SIGTERM` the process stops the tick loop and (by +default) cancels all resting orders before exiting. Set +`cancelOrdersOnShutdown: false` in the config to leave resting orders +on the book for hot restarts. ## Configuration -The MM ships per-app, per-env YAML configs under `configs/`: - -| File | Network | Notes | -|---|---|---| -| `perps.local.yml` / `futures.local.yml` | hardhat | Local development; dry-run on by default | -| `perps.dev.yml` / `futures.dev.yml` | base-sepolia | Testnet; small sizes | -| `perps.stg.yml` / `futures.stg.yml` | base | Pre-prod on mainnet; conservative caps | -| `perps.prd.yml` / `futures.prd.yml` | base | Production; full sizes | - -Pick one with `--config ` (CLI arg), `MAKER_CONFIG=` (env), -or `MAKER_ENV=` inside the docker entrypoint. +Each app ships per-environment YAML configs under `configs/`: -Precedence: `--config` CLI arg > `MAKER_CONFIG` env > docker `MAKER_ENV` lookup. - -The YAMLs interpolate `${VAR}` tokens from environment variables. On -startup both apps load `.env` from `market-maker/` and from -`collateral-margin/` (in that priority order). Live `process.env` always -wins over file contents. +| File | Network | +|---|---| +| `perps.local.yml` / `futures.local.yml` | hardhat | +| `perps.dev.yml` / `futures.dev.yml` | base-sepolia | +| `perps.stg.yml` / `futures.stg.yml` | base-mainnet | +| `perps.prd.yml` / `futures.prd.yml` | base-mainnet | + +Pick one with `--config ` (CLI flag), `MAKER_CONFIG=` (env +variable), or `MAKER_ENV=` inside the docker +entrypoint. Precedence is `--config` > `MAKER_CONFIG` > docker +`MAKER_ENV` lookup. + +The YAMLs are validated against generated JSON Schemas (autocomplete +and type-checking work in any editor with the YAML extension). They +interpolate `${VAR}` tokens from environment variables. On startup +both apps load `.env` from `market-maker/` and from the parent +`collateral-margin/` (in that priority order); live `process.env` +always wins over file contents. ```bash pnpm local:perps # node … --config configs/perps.local.yml | pino-pretty @@ -107,76 +157,41 @@ pnpm dev:futures # node … --config configs/futures.dev.yml | pino-pretty pnpm stg:perps # node … --config configs/perps.stg.yml pnpm prd:futures # node … --config configs/futures.prd.yml -# Custom path (e.g. one-off experiment): +# One-off / custom path: node src/apps/perps/main.ts --config /tmp/my-perps.yml ``` -### Required - -| Variable | Used by | Description | -|---|---|---| -| `PRIVATE_KEY` | all envs | Hex-encoded private key for the MM wallet | -| `ALCHEMY_API_KEY` | dev/stg/prd | Alchemy API key. The bundled configs compose the RPC URL (`https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}` for dev, `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}` for stg/prd). | -| `PERPS_ADDRESS` | perps app | Deployed HashPowerPerpsDEX proxy contract address | -| `FUTURES_ADDRESS` | futures app | Deployed Futures proxy contract address | -| `ETH_PRICE_FEED_ADDRESS` | optional | Chainlink ETH/USD aggregator. Required to surface gas cost in USD; leave unset for local hardhat. | - -### Quoting +All operational tuning (sizes, spreads, risk caps, gas budgets, +timings, log level) lives in the YAML files. Refer to those for the +authoritative list of fields. -| Variable | Default | Description | -|---|---|---| -| `MAKER_LEVELS_PER_SIDE` | `5` | Number of bid/ask levels to quote | -| `MAKER_BASE_QUANTITY` | `1000000` | Base order size (in quantity decimals) | -| `MAKER_MIN_SPREAD_BPS` | `10` | Minimum spread in basis points | -| `MAKER_VOLATILITY_MULTIPLIER` | `2.0` | Volatility scaling factor | -| `MAKER_INVENTORY_SKEW_GAMMA` | `0.5` | Inventory skew strength (0 = disabled, 1 = max) | -| `MAKER_MAX_SKEW_TICKS` | `20` | Maximum skew offset in tick units | - -### Gas management - -| Variable | Default | Description | -|---|---|---| -| `ETH_PRICE_FEED_ADDRESS` | *(none)* | Chainlink ETH/USD price feed address (enables USD gas cost tracking) | -| `MAKER_GAS_SPIKE_THRESHOLD_PCT` | `200` | Gas price % above median to trigger spike mode | -| `MAKER_GAS_CAP_MULTIPLIER` | `2.0` | Max gas price as multiple of median | -| `MAKER_GAS_PENALTY_BPS` | `5` | Additional spread penalty per 100% gas spike | -| `MAKER_MAX_GAS_BUDGET_HOUR_USD` | `50000000` | Max gas spend per rolling hour (collateral decimals) | -| `MAKER_MAX_GAS_BUDGET_DAY_USD` | `500000000` | Max gas spend per rolling day (collateral decimals) | -| `MAKER_URGENT_REQUOTE_TICKS` | `10` | Price drift in ticks that overrides gas spike deferral | - -### Risk +### Required environment variables -| Variable | Default | Description | -|---|---|---| -| `MAKER_MAX_POSITION_SIZE` | `100000000` | Max absolute net position (quantity decimals) | -| `MAKER_MAX_UTILIZATION_PCT` | `80` | Max margin utilization before side restrictions | -| `MAKER_MIN_COLLATERAL` | `100000000` | Minimum collateral balance before halt (collateral decimals) | -| `MAKER_MAX_DAILY_LOSS_USD` | `1000000000` | Max daily loss including gas (collateral decimals) | - -### Timing +These must be set in `.env` (or the live environment); everything +else lives in YAML. -| Variable | Default | Description | +| Variable | Required by | Description | |---|---|---| -| `MAKER_POLL_INTERVAL_MS` | `3000` | Main loop interval | -| `MAKER_REQUOTE_THRESHOLD_TICKS` | `2` | Price drift in ticks before requoting | -| `MAKER_REQUOTE_COOLDOWN_MS` | `1000` | Minimum time between requotes | -| `MAKER_RESYNC_INTERVAL_MS` | `60000` | Full order book resync interval | +| `PRIVATE_KEY` | all | Hex-encoded private key for the MM wallet | +| `ALCHEMY_API_KEY` | dev / stg / prd | Used by the bundled YAMLs to compose the RPC URL | +| `PERPS_ADDRESS` | perps app | Deployed `HashPowerPerpsDEX` proxy address | +| `FUTURES_ADDRESS` | futures app | Deployed `Futures` proxy address | -### Operational - -| Variable | Default | Description | -|---|---|---| -| `MAKER_DRY_RUN` | `false` | Log orders without submitting transactions | -| `MAKER_HEALTH_PORT` | `3001` | HTTP health endpoint port | -| `MAKER_LOG_LEVEL` | `info` | Pino log level: `trace`, `debug`, `info`, `warn`, `error`, `silent` | +Custom YAMLs may reference additional `${VAR}` tokens (e.g. a +non-Alchemy RPC URL, a chain id override). The bundled YAMLs in +`configs/` only reference the four above plus the RPC URL. ## Getting started ### Prerequisites -- Node.js >= 22.6.0 -- pnpm >= 10 -- Compiled contracts (ABIs) +- Node.js ≥ 22.6.0 +- pnpm ≥ 10 + +ABIs are pulled directly from the upstream contract repos as Git +dependencies (`futures-contracts`, `perps-contracts`, +`collateral-margin-contracts`). No manual sync is required — `pnpm +install` is enough. ### Install @@ -185,16 +200,6 @@ cd market-maker pnpm install ``` -### Build ABIs - -The market maker uses ABIs generated from the contracts package. The `pretest` script handles this automatically for tests, but for manual setup: - -```bash -cd contracts -pnpm hardhat compile -cp abi/abi.ts ../market-maker/src/abi.ts -``` - ### Run ```bash @@ -215,94 +220,22 @@ pnpm prd:perps pnpm prd:futures ``` -### Example `.env` - -```env -PRIVATE_KEY=0x... -ALCHEMY_API_KEY=... -PERPS_ADDRESS=0x... -FUTURES_ADDRESS=0x... -MAKER_DRY_RUN=false -MAKER_HEALTH_PORT=3001 -``` - ## Health endpoint -`GET http://localhost:{MAKER_HEALTH_PORT}/health` returns JSON: - -```json -{ - "status": "running", - "haltReason": "none", - "throttled": false, - "throttleReason": "none", - "oraclePrice": "2997635", - "volatility": 0.0012, - "netPosition": "-1000000", - "collateral": "100000000000", - "inventorySkew": -0.01, - "utilizationPct": 5, - "ownOrders": 6, - "bestBid": "2990000", - "bestAsk": "3010000", - "gasGwei": "0.10", - "gasSpiking": false, - "gasSpikePct": "0", - "cumulativeGasCostUsd": "0", - "tickCount": 142, - "lastTickAt": 1709500000000, - "ordersPlaced": 12, - "ordersCancelled": 6, - "reconcileCount": 3, - "uptimeSeconds": 426, - "dryRun": false -} -``` - -| Field | Description | -|---|---| -| `status` | `running` or `halted` | -| `haltReason` | `none`, `drawdown`, or `daily_loss` | -| `throttled` | Whether gas budget throttling is active | -| `oraclePrice` | Current oracle price (contract decimals) | -| `volatility` | Rolling price volatility (0-1 scale) | -| `netPosition` | Signed net position size | -| `collateral` | Collateral balance (collateral token decimals) | -| `inventorySkew` | Position skew ratio (-1 to 1) | -| `utilizationPct` | Margin utilization percentage | -| `ownOrders` | Number of resting orders on-chain | -| `bestBid` / `bestAsk` | Current top-of-book prices | -| `ordersPlaced` / `ordersCancelled` | Cumulative order counts since startup | -| `reconcileCount` | Number of requote cycles executed | +`GET http://localhost:{healthPort}/health` returns a JSON snapshot of +live operational state — status, halt/throttle reasons, oracle and +gas readings, position and collateral, order counts, and uptime. +Suitable for liveness/readiness probes and for scraping into a +dashboard. The exact field set is exercised by +`tests/core/healthcheck.test.ts`. ## Testing ```bash -# Run all tests (unit + e2e) pnpm test - -# With coverage report -pnpm test:coverage - -# Watch mode -pnpm test:watch - -# Run only process-level e2e tests -node --test --test-force-exit --test-concurrency=1 'tests/market-maker.process.test.ts' ``` -Tests use a local Hardhat node started automatically. The test suite includes: - -- **Unit tests**: each component tested in isolation with mocked dependencies -- **Component e2e tests** (`market-maker.e2e.test.ts`): full component stack wired together against a local Hardhat node -- **Process e2e tests** (`market-maker.process.test.ts`): spawns the market maker as a separate OS process, verifies behavior by querying on-chain state and the health API - -## Supported networks - -| Network | Chain ID | Notes | -|---|---|---| -| `arbitrum` | 42161 | Production | -| `arbitrum-sepolia` | 421614 | Testnet | -| `hardhat` | 31337 | Local development | - -Transport is selected automatically: WebSocket URLs (`ws://` / `wss://`) use WebSocket transport, otherwise HTTP. +The suite uses Node's built-in test runner (`node --test`) with +TypeScript strip mode — no transpile step. Tests run unit-only +against in-memory mocks of the adapters; venue end-to-end checks live +in the upstream contract repos. diff --git a/market-maker/package.json b/market-maker/package.json index a49b33a..adec1fd 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -17,16 +17,17 @@ "stg:futures": "node src/apps/futures/main.ts --config configs/futures.stg.yml", "prd:perps": "node src/apps/perps/main.ts --config configs/perps.prd.yml", "prd:futures": "node src/apps/futures/main.ts --config configs/futures.prd.yml", - "sync-abi": "./scripts/sync-abi.sh", "gen:schemas": "node scripts/gen-schemas.ts", - "pretest": "./scripts/sync-abi.sh", "pretypecheck": "node scripts/gen-schemas.ts" }, "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "collateral-margin-contracts": "link:../contracts", "fraction.js": "^5.2.2", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", "js-yaml": "^4.1.0", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 5963d78..669b4c6 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -14,12 +14,21 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.18.0) + collateral-margin-contracts: + specifier: link:../contracts + version: link:../contracts fraction.js: specifier: ^5.2.2 version: 5.3.4 + futures-contracts: + specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#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 + perps-contracts: + specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -98,305 +107,5071 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} + '@arbitrum/nitro-contracts@3.0.0': + resolution: {integrity: sha512-7VzNW9TxvrX9iONDDsi7AZlEUPa6z+cjBkB4Mxlnog9VQZAapRC3CdRXyUzHnBYmUhRzyNJdyxkWPw59QGcLmA==} - '@noble/curves@1.9.1': - resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} - engines: {node: ^14.21.3 || >=16} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@aws-crypto/sha256-js@1.2.2': + resolution: {integrity: sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g==} - '@scure/base@1.2.6': - resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} - '@scure/bip32@1.7.0': - resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - '@scure/bip39@1.6.0': - resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@aws-crypto/util@1.2.2': + resolution: {integrity: sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg==} - '@sinclair/typebox@0.34.49': - resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@aws-sdk/client-lambda@3.1042.0': + resolution: {integrity: sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==} + engines: {node: '>=20.0.0'} - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + engines: {node: '>=20.0.0'} - abitype@1.2.3: - resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} - peerDependencies: - typescript: '>=5.0.4' - zod: ^3.22.0 || ^4.0.0 - peerDependenciesMeta: - typescript: - optional: true - zod: - optional: true + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + engines: {node: '>=20.0.0'} - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.13': + resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.8': + resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} + engines: {node: '>=20.0.0'} peerDependencies: - ajv: ^8.0.0 + aws-crt: '>=1.0.0' peerDependenciesMeta: - ajv: + aws-crt: optional: true - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + '@aws-sdk/util-utf8-browser@3.259.0': + resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + engines: {node: '>=20.0.0'} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} - dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + '@bytecodealliance/preview2-shim@0.17.0': + resolution: {integrity: sha512-JorcEwe4ud0x5BS/Ar2aQWOQoFzjq/7jcnxYXCvSMh0oRm0dQXzOA+hqLDBnOMks1LLBA7dmiLLsEBl09Yd6iQ==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + '@chainlink/contracts@1.5.0': + resolution: {integrity: sha512-1fGJwjvivqAxvVOTqZUEXGR54CATtg0vjcXgSIk4Cfoad2nUhSG/qaWHXjLg1CkNTeOoteoxGQcpP/HiA5HsUA==} + engines: {node: '>=22', pnpm: '>=10'} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - fast-copy@4.0.3: - resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - isows@1.0.7: - resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + '@changesets/get-github-info@0.6.0': + resolution: {integrity: sha512-v/TSnFVXI8vzX9/w3DU2Ol+UlTZcu3m0kXTjTT4KlAdwSvwutcByYwyYn9hwerPWfPkT2JfpoX0KgvCEi8Q/SA==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eth-optimism/contracts@0.6.0': + resolution: {integrity: sha512-vQ04wfG9kMf1Fwy3FEMqH2QZbgS0gldKhcBeBUPfO8zu68L61VI97UDXmsMQXzTsEAxK8HnokW3/gosl4/NW3w==} peerDependencies: - ws: '*' + ethers: ^5 - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} + '@eth-optimism/core-utils@0.12.0': + resolution: {integrity: sha512-qW+7LZYCz7i8dRa7SRlUKIo1VBU8lvN0HeXCxJR+z+xtMzMQpPds20XJNCMclszxYQHkXY00fOT6GvFw9ZL6nw==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + '@ethereumjs/rlp@5.0.2': + resolution: {integrity: sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA==} + engines: {node: '>=18'} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + '@ethereumjs/util@9.1.0': + resolution: {integrity: sha512-XBEKsYqLGXLah9PNJbgdkigthkG7TAGvlD/sH12beMXEyHDyigfcbdvHhmLyDWgDyOJn4QwiQUaF7yeuhnjdog==} + engines: {node: '>=18'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + '@ethersproject/abi@5.8.0': + resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} + '@ethersproject/abstract-provider@5.8.0': + resolution: {integrity: sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + '@ethersproject/abstract-signer@5.8.0': + resolution: {integrity: sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==} - ox@0.14.20: - resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + '@ethersproject/address@5.8.0': + resolution: {integrity: sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==} + + '@ethersproject/base64@5.8.0': + resolution: {integrity: sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==} + + '@ethersproject/basex@5.8.0': + resolution: {integrity: sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==} + + '@ethersproject/bignumber@5.8.0': + resolution: {integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==} + + '@ethersproject/bytes@5.8.0': + resolution: {integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==} + + '@ethersproject/constants@5.8.0': + resolution: {integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==} + + '@ethersproject/contracts@5.8.0': + resolution: {integrity: sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==} + + '@ethersproject/hash@5.8.0': + resolution: {integrity: sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==} + + '@ethersproject/hdnode@5.8.0': + resolution: {integrity: sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==} + + '@ethersproject/json-wallets@5.8.0': + resolution: {integrity: sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==} + + '@ethersproject/keccak256@5.8.0': + resolution: {integrity: sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==} + + '@ethersproject/logger@5.8.0': + resolution: {integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==} + + '@ethersproject/networks@5.8.0': + resolution: {integrity: sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==} + + '@ethersproject/pbkdf2@5.8.0': + resolution: {integrity: sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==} + + '@ethersproject/properties@5.8.0': + resolution: {integrity: sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==} + + '@ethersproject/providers@5.8.0': + resolution: {integrity: sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==} + + '@ethersproject/random@5.8.0': + resolution: {integrity: sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==} + + '@ethersproject/rlp@5.8.0': + resolution: {integrity: sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==} + + '@ethersproject/sha2@5.8.0': + resolution: {integrity: sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==} + + '@ethersproject/signing-key@5.8.0': + resolution: {integrity: sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==} + + '@ethersproject/solidity@5.8.0': + resolution: {integrity: sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA==} + + '@ethersproject/strings@5.8.0': + resolution: {integrity: sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==} + + '@ethersproject/transactions@5.8.0': + resolution: {integrity: sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==} + + '@ethersproject/units@5.8.0': + resolution: {integrity: sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==} + + '@ethersproject/wallet@5.8.0': + resolution: {integrity: sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==} + + '@ethersproject/web@5.8.0': + resolution: {integrity: sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==} + + '@ethersproject/wordlists@5.8.0': + resolution: {integrity: sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==} + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} peerDependencies: - typescript: '>=5.4.0' + '@types/node': '>=18' peerDependenciesMeta: - typescript: + '@types/node': optional: true - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true + '@noble/curves@1.4.2': + resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + '@noble/curves@1.8.2': + resolution: {integrity: sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==} + engines: {node: ^14.21.3 || >=16} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + '@noble/hashes@1.2.0': + resolution: {integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + '@noble/hashes@1.7.2': + resolution: {integrity: sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==} + engines: {node: ^14.21.3 || >=16} - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + '@noble/secp256k1@1.7.1': + resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} - engines: {node: '>=20'} + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true + '@nomicfoundation/edr-darwin-arm64@0.12.0-next.23': + resolution: {integrity: sha512-Amh7mRoDzZyJJ4efqoePqdoZOzharmSOttZuJDlVE5yy07BoE8hL6ZRpa5fNYn0LCqn/KoWs8OHANWxhKDGhvQ==} + engines: {node: '>= 20'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + '@nomicfoundation/edr-darwin-x64@0.12.0-next.23': + resolution: {integrity: sha512-9wn489FIQm7m0UCD+HhktjWx6vskZzeZD9oDc2k9ZvbBzdXwPp5tiDqUBJ+eQpByAzCDfteAJwRn2lQCE0U+Iw==} + engines: {node: '>= 20'} - viem@2.48.4: - resolution: {integrity: sha512-mReP/rgY2P+WeeRSG4sUvccCLKfyAW1C73Y3KkobAqgzYmVna9qyUMNE44xIUkDtfvRuC33r24UhF4baBYovsg==} + '@nomicfoundation/edr-linux-arm64-gnu@0.12.0-next.23': + resolution: {integrity: sha512-nlk5EejSzEUfEngv0Jkhqq3/wINIfF2ED9wAofc22w/V1DV99ASh9l3/e/MIHOQFecIZ9MDqt0Em9/oDyB1Uew==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-arm64-musl@0.12.0-next.23': + resolution: {integrity: sha512-SJuPBp3Rc6vM92UtVTUxZQ/QlLhLfwTftt2XUiYohmGKB3RjGzpgduEFMCA0LEnucUckU6UHrJNFHiDm77C4PQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-x64-gnu@0.12.0-next.23': + resolution: {integrity: sha512-NU+Qs3u7Qt6t3bJFdmmjd5CsvgI2bPPzO31KifM2Ez96/jsXYho5debtTQnimlb5NAqiHTSlxjh/F8ROcptmeQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-x64-musl@0.12.0-next.23': + resolution: {integrity: sha512-F78fZA2h6/ssiCSZOovlgIu0dUeI7ItKPsDDF3UUlIibef052GCXmliMinC90jVPbrjUADMd1BUwjfI0Z8OllQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-win32-x64-msvc@0.12.0-next.23': + resolution: {integrity: sha512-IfJZQJn7d/YyqhmguBIGoCKjE9dKjbu6V6iNEPApfwf5JyyjHYyyfkLU4rf7hygj57bfH4sl1jtQ6r8HnT62lw==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr@0.12.0-next.23': + resolution: {integrity: sha512-F2/6HZh8Q9RsgkOIkRrckldbhPjIZY7d4mT9LYuW68miwGQ5l7CkAgcz9fRRiurA0+YJhtsbx/EyrD9DmX9BOw==} + engines: {node: '>= 20'} + + '@nomicfoundation/hardhat-ethers@3.1.3': + resolution: {integrity: sha512-208JcDeVIl+7Wu3MhFUUtiA8TJ7r2Rn3Wr+lSx9PfsDTKkbsAsWPY6N6wQ4mtzDv0/pB9nIbJhkjoHe1EsgNsA==} peerDependencies: - typescript: '>=5.0.4' - peerDependenciesMeta: - typescript: - optional: true + ethers: ^6.14.0 + hardhat: ^2.28.0 - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + '@nomicfoundation/slang@0.18.3': + resolution: {integrity: sha512-YqAWgckqbHM0/CZxi9Nlf4hjk9wUNLC9ngWCWBiqMxPIZmzsVKYuChdlrfeBPQyvQQBoOhbx+7C1005kLVQDZQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} + '@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.2': + resolution: {integrity: sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-darwin-x64@0.1.2': + resolution: {integrity: sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.2': + resolution: {integrity: sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.2': + resolution: {integrity: sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.2': + resolution: {integrity: sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.2': + resolution: {integrity: sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.2': + resolution: {integrity: sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer@0.1.2': + resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==} + engines: {node: '>= 12'} + + '@offchainlabs/upgrade-executor@1.1.0-beta.0': + resolution: {integrity: sha512-mpn6PHjH/KDDjNX0pXHEKdyv8m6DVGQiI2nGzQn0JbM1nOSHJpWx6fvfjtH7YxHJ6zBZTcsKkqGkFKDtCfoSLw==} + + '@openzeppelin/contracts-upgradeable@4.7.3': + resolution: {integrity: sha512-+wuegAMaLcZnLCJIvrVUDzA9z/Wp93f0Dla/4jJvIhijRrPabjQbZe6fWiECLaJyfn5ci9fqf9vTw3xpQOad2A==} + + '@openzeppelin/contracts-upgradeable@4.9.6': + resolution: {integrity: sha512-m4iHazOsOCv1DgM7eD7GupTJ+NFVujRZt1wzddDPSVGpWdKq1SKkla5htKG7+IS4d2XOCtzkUNwRZ7Vq5aEUMA==} + + '@openzeppelin/contracts-upgradeable@5.1.0': + resolution: {integrity: sha512-AIElwP5Ck+cslNE+Hkemf5SxjJoF4wBvvjxc27Rp+9jaPs/CLIaUBMYe1FNzhdiN0cYuwGRmYaRHmmntuiju4Q==} peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' + '@openzeppelin/contracts': 5.1.0 + + '@openzeppelin/contracts@4.7.3': + resolution: {integrity: sha512-dGRS0agJzu8ybo44pCIf3xBaPQN/65AIXNgK8+4gzKd5kbvlqyxryUYVLJv7fK98Seyd2hDZzVEHSWAh0Bt1Yw==} + + '@openzeppelin/contracts@4.8.3': + resolution: {integrity: sha512-bQHV8R9Me8IaJoJ2vPG4rXcL7seB7YVuskr4f+f5RyOStSZetwzkWtoqDMl5erkBJy0lDRUnIR2WIkPiC0GJlg==} + + '@openzeppelin/contracts@4.9.6': + resolution: {integrity: sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA==} + + '@openzeppelin/contracts@5.0.2': + resolution: {integrity: sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==} + + '@openzeppelin/contracts@5.1.0': + resolution: {integrity: sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==} + + '@openzeppelin/defender-sdk-base-client@2.7.1': + resolution: {integrity: sha512-7gFCteA+V3396A3McgqzmirwmbPXuHJYN896O3AbsHX9XcxInN74C5Zv3tFHld0GmIX/VlaIvILNMhOpdISZjA==} + + '@openzeppelin/defender-sdk-deploy-client@2.7.1': + resolution: {integrity: sha512-vFkDupn8ATW83KjZlY5U7UdsvSo9YZwOMQoVaHJO3S+Z6h0wa6cTzuQV9C0AKYq524quQkFsQ4AQq5CgsgdEkQ==} + + '@openzeppelin/defender-sdk-network-client@2.7.1': + resolution: {integrity: sha512-AWJKT9YKv9wH3/1AJZCztF3VIsg1sX+v8fjtyFLROqtVAzmhB8WKBRVt9GHAZ+PmsixAKDMOEbH6R1cipTIVHQ==} + + '@openzeppelin/hardhat-upgrades@3.9.1': + resolution: {integrity: sha512-pSDjlOnIpP+PqaJVe144dK6VVKZw2v6YQusyt0OOLiCsl+WUzfo4D0kylax7zjrOxqy41EK2ipQeIF4T+cCn2A==} + hasBin: true + peerDependencies: + '@nomicfoundation/hardhat-ethers': ^3.0.6 + '@nomicfoundation/hardhat-verify': ^2.0.14 + ethers: ^6.6.0 + hardhat: ^2.24.1 peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: + '@nomicfoundation/hardhat-verify': optional: true -snapshots: + '@openzeppelin/upgrades-core@1.44.2': + 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@3.0.2': + resolution: {integrity: sha512-+DQm88HRe4bDOHsvi/9HKT3foXe1aS1iOKyu2yAqnJOAXURtdjhYmsMHzsseA/3qzlepC9SdC/ZBwr2Do0vzgQ==} + + '@safe-global/protocol-kit@6.1.2': + resolution: {integrity: sha512-cTpPdUAS2AMfGCkD1T601rQNjT0rtMQLA2TH7L/C+iFPAC6WrrDFop2B9lzeHjczlnVzrRpfFe4cL1bLrJ9NZw==} + + '@safe-global/safe-deployments@1.37.56': + resolution: {integrity: sha512-HF3ETre/KSP3nCOZ72XEbq5U56gOGgYLJ22LxOAnR8+YzMjzZ8cpnHpx5Z31Zt1xkUTGSCMi9XF950lJt6WbsQ==} + + '@safe-global/safe-modules-deployments@2.2.25': + resolution: {integrity: sha512-KjgenKhBRyFHEfo8xlBgNzKAy25vrmGyCGZwTjIuA81yOSRJRe85GE5Yfg/FBKeeyHqR2dD1WPZr6c2Uqd6C/g==} + + '@safe-global/types-kit@2.0.1': + resolution: {integrity: sha512-4xKjTBlyFSIKziqvjrGMBAgs7Z2+s/5A2wjAXg2gBA1BuvV6w1THk1Y/WMZg8+6/PlRaVMVo4LoSNMRSsZZhjw==} + + '@safe-global/types-kit@3.1.0': + resolution: {integrity: sha512-uI6lFV8wOji4rb7juu0/LRMND0rq2NWaqH4zFzE2FUjO8lTvbCT+/5W/+flUUfkcaqyLDlJ1OPjasa2bEakrbw==} + + '@scroll-tech/contracts@2.0.0': + resolution: {integrity: sha512-O8sVaA/bVKH/mp+bBfUjZ/vYr5mdBExCpKRLre4r9TbXTtiaY9Uo5xU8dcG3weLxyK0BZqDTP2aCNp4Q0f7SeA==} + + '@scure/base@1.1.9': + resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.1.5': + resolution: {integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==} + + '@scure/bip32@1.4.0': + resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.1.1': + resolution: {integrity: sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==} + + '@scure/bip39@1.3.0': + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@sentry/core@5.30.0': + resolution: {integrity: sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==} + engines: {node: '>=6'} + + '@sentry/hub@5.30.0': + resolution: {integrity: sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==} + engines: {node: '>=6'} + + '@sentry/minimal@5.30.0': + resolution: {integrity: sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==} + engines: {node: '>=6'} + + '@sentry/node@5.30.0': + resolution: {integrity: sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg==} + engines: {node: '>=6'} + + '@sentry/tracing@5.30.0': + resolution: {integrity: sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw==} + engines: {node: '>=6'} + + '@sentry/types@5.30.0': + resolution: {integrity: sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==} + engines: {node: '>=6'} + + '@sentry/utils@5.30.0': + resolution: {integrity: sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==} + engines: {node: '>=6'} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.3.0': + resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + + '@types/bn.js@5.2.0': + resolution: {integrity: sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/pbkdf2@3.1.2': + resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} + + '@types/secp256k1@4.0.7': + resolution: {integrity: sha512-Rcvjl6vARGAKRO6jHeKMatGrvOMGrR/AR11N1x2LqintPCyDZ7NBhrh238Z2VZc7aM7KIwnFpFQ7fnfK4H/9Qw==} + + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.4.16: + resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==} + engines: {node: '>=0.3.0'} + + aes-js@3.0.0: + resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + amazon-cognito-identity-js@6.3.16: + resolution: {integrity: sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + 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==} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bech32@1.1.4: + resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + blakejs@1.2.1: + resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + bn.js@5.2.3: + resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + bs58check@2.1.2: + resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + + buffer@4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} + + bufio@1.2.3: + resolution: {integrity: sha512-5Tt66bRzYUSlVZatc0E92uDenreJ+DpTBmSAUwL4VSxJn3e6cUyYwx+PoqML0GRZatgA/VX8ybhxItF8InZgqA==} + engines: {node: '>=8.0.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + cbor@10.0.12: + resolution: {integrity: sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==} + engines: {node: '>=20'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8} + version: 1.0.0 + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + command-exists@1.2.9: + resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.4.2: + resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dataloader@1.4.0: + resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc} + version: 1.0.0 + engines: {node: 24.x} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + 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} + version: 0.1.0 + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + ethereum-cryptography@0.1.3: + resolution: {integrity: sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ==} + + ethereum-cryptography@1.2.0: + resolution: {integrity: sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw==} + + ethereum-cryptography@2.2.1: + resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + + ethereumjs-util@7.1.5: + resolution: {integrity: sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg==} + engines: {node: '>=10.0.0'} + + ethers@5.8.0: + resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-base64-decode@1.0.0: + resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==} + + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-builder@1.1.8: + resolution: {integrity: sha512-sDVBc2gg8pSKvcbE8rBmOyjSGQf0AdsbqvHeIOv3D/uYNoV4eCReQXyDF8Pdv8+m1FHazACypSz2hR7O2S1LLw==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-yarn-workspace-root@2.0.0: + resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fp-ts@1.19.3: + resolution: {integrity: sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a} + 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.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hardhat@2.28.6: + resolution: {integrity: sha512-zQze7qe+8ltwHvhX5NQ8sN1N37WWZGw8L63y+2XcPxGwAjc/SMF829z3NS6o1krX0sryhAsVBK/xrwUqlsot4Q==} + hasBin: true + peerDependencies: + ts-node: '*' + typescript: '*' + peerDependenciesMeta: + ts-node: + optional: true + typescript: + optional: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8} + version: 1.0.0 + + 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} + version: 1.0.0 + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@4.3.8: + resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + io-ts@1.10.4: + resolution: {integrity: sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-ci@2.0.0: + resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-unfetch@3.1.0: + resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-cookie@2.2.1: + resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} + + js-sha3@0.8.0: + resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stream-stringify@3.1.6: + resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} + engines: {node: '>=7.10.1'} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + keccak@3.0.4: + resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} + engines: {node: '>=10.0.0'} + + klaw-sync@6.0.0: + resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + lru_map@0.3.3: + resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micro-eth-signer@0.14.0: + resolution: {integrity: sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==} + + micro-packed@0.7.3: + resolution: {integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mnemonist@0.38.5: + resolution: {integrity: sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg==} + + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.0'} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + 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 + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + node-addon-api@2.0.2: + resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + nofilter@3.1.0: + resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} + engines: {node: '>=12.19'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + patch-package@6.5.1: + resolution: {integrity: sha512-I/4Zsalfhc6bphmJTlrLoOcAF87jcxko4q0qsv4bGcurbr8IskEOtdnt9iCmsQVGL1B+iUhSQqweyTLJfCF9rA==} + engines: {node: '>=10', npm: '>5'} + hasBin: true + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + pbkdf2@3.1.5: + resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} + engines: {node: '>= 0.10'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + 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==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.17.0: + resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + + rlp@2.2.7: + resolution: {integrity: sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + + secp256k1@4.0.4: + resolution: {integrity: sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==} + engines: {node: '>=18.0.0'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + solady@0.0.182: + resolution: {integrity: sha512-FW6xo1akJoYpkXMzu58/56FcNU3HYYNamEbnFO3iSibXk0nSHo0DV2Gu/zI3FPg3So5CCX6IYli1TT1IWATnvg==} + + solc@0.8.26: + resolution: {integrity: sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==} + engines: {node: '>=10.0.0'} + hasBin: true + + solidity-ast@0.4.62: + resolution: {integrity: sha512-jSC7msQCkJXIzM8LlDjRZ5cif5w40g6THlXHFk3zchbL5dm3YLoBETvqPGo5KndYkftjhcs5kz1fnTu4d34lVQ==} + + solidity-linked-list@6.5.0: + resolution: {integrity: sha512-V7nGnmXQ02zzWEXL8ZrKi79KssUW9lofnrnV7mQ0tAVr2QzIiV4RINVbLQGdMUohOOnPEVW59OshTbByo0pNyw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsort@0.0.1: + resolution: {integrity: sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw==} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + unfetch@4.2.0: + resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + viem@2.48.4: + resolution: {integrity: sha512-mReP/rgY2P+WeeRSG4sUvccCLKfyAW1C73Y3KkobAqgzYmVna9qyUMNE44xIUkDtfvRuC33r24UhF4baBYovsg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@arbitrum/nitro-contracts@3.0.0': + dependencies: + '@offchainlabs/upgrade-executor': 1.1.0-beta.0 + '@openzeppelin/contracts': 4.7.3 + '@openzeppelin/contracts-upgradeable': 4.7.3 + patch-package: 6.5.1 + solady: 0.0.182 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@1.2.2': + dependencies: + '@aws-crypto/util': 1.2.2 + '@aws-sdk/types': 3.973.8 + tslib: 1.14.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@1.2.2': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-utf8-browser': 3.259.0 + tslib: 1.14.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-lambda@3.1042.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.36': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.39': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.8 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.6': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.25': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1041.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.24': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/util-utf8-browser@3.259.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.22': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@babel/runtime@7.29.2': {} + + '@bytecodealliance/preview2-shim@0.17.0': {} + + '@chainlink/contracts@1.5.0(@types/node@22.19.17)(ethers@5.8.0)': + dependencies: + '@arbitrum/nitro-contracts': 3.0.0 + '@changesets/cli': 2.31.0(@types/node@22.19.17) + '@changesets/get-github-info': 0.6.0 + '@eslint/eslintrc': 3.3.5 + '@eth-optimism/contracts': 0.6.0(ethers@5.8.0) + '@openzeppelin/contracts-4.7.3': '@openzeppelin/contracts@4.7.3' + '@openzeppelin/contracts-4.8.3': '@openzeppelin/contracts@4.8.3' + '@openzeppelin/contracts-4.9.6': '@openzeppelin/contracts@4.9.6' + '@openzeppelin/contracts-5.0.2': '@openzeppelin/contracts@5.0.2' + '@openzeppelin/contracts-5.1.0': '@openzeppelin/contracts@5.1.0' + '@openzeppelin/contracts-upgradeable': 4.9.6 + '@scroll-tech/contracts': 2.0.0 + '@zksync/contracts': era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9 + semver: 7.7.4 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - ethers + - supports-color + - utf-8-validate + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.4 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.4 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.0(@types/node@22.19.17)': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@22.19.17) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.4 + + '@changesets/get-github-info@0.6.0': + dependencies: + dataloader: 1.4.0 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@8.1.1) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eth-optimism/contracts@0.6.0(ethers@5.8.0)': + dependencies: + '@eth-optimism/core-utils': 0.12.0 + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/abstract-signer': 5.8.0 + ethers: 5.8.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@eth-optimism/core-utils@0.12.0': + dependencies: + '@ethersproject/abi': 5.8.0 + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/contracts': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/providers': 5.8.0 + '@ethersproject/rlp': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/web': 5.8.0 + bufio: 1.2.3 + chai: 4.5.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@ethereumjs/rlp@5.0.2': {} + + '@ethereumjs/util@9.1.0': + dependencies: + '@ethereumjs/rlp': 5.0.2 + ethereum-cryptography: 2.2.1 + + '@ethersproject/abi@5.8.0': + dependencies: + '@ethersproject/address': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/strings': 5.8.0 + + '@ethersproject/abstract-provider@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/networks': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/web': 5.8.0 + + '@ethersproject/abstract-signer@5.8.0': + dependencies: + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + + '@ethersproject/address@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/rlp': 5.8.0 + + '@ethersproject/base64@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + + '@ethersproject/basex@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/properties': 5.8.0 + + '@ethersproject/bignumber@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + bn.js: 5.2.3 + + '@ethersproject/bytes@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/constants@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + + '@ethersproject/contracts@5.8.0': + dependencies: + '@ethersproject/abi': 5.8.0 + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/transactions': 5.8.0 + + '@ethersproject/hash@5.8.0': + dependencies: + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/base64': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/strings': 5.8.0 + + '@ethersproject/hdnode@5.8.0': + dependencies: + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/basex': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/pbkdf2': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/sha2': 5.8.0 + '@ethersproject/signing-key': 5.8.0 + '@ethersproject/strings': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/wordlists': 5.8.0 + + '@ethersproject/json-wallets@5.8.0': + dependencies: + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/hdnode': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/pbkdf2': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/random': 5.8.0 + '@ethersproject/strings': 5.8.0 + '@ethersproject/transactions': 5.8.0 + aes-js: 3.0.0 + scrypt-js: 3.0.1 + + '@ethersproject/keccak256@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + js-sha3: 0.8.0 + + '@ethersproject/logger@5.8.0': {} + + '@ethersproject/networks@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/pbkdf2@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/sha2': 5.8.0 + + '@ethersproject/properties@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/providers@5.8.0': + dependencies: + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/base64': 5.8.0 + '@ethersproject/basex': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/networks': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/random': 5.8.0 + '@ethersproject/rlp': 5.8.0 + '@ethersproject/sha2': 5.8.0 + '@ethersproject/strings': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/web': 5.8.0 + bech32: 1.1.4 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@ethersproject/random@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + + '@ethersproject/rlp@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + + '@ethersproject/sha2@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + hash.js: 1.1.7 + + '@ethersproject/signing-key@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + bn.js: 5.2.3 + elliptic: 6.6.1 + hash.js: 1.1.7 + + '@ethersproject/solidity@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/sha2': 5.8.0 + '@ethersproject/strings': 5.8.0 + + '@ethersproject/strings@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/logger': 5.8.0 + + '@ethersproject/transactions@5.8.0': + dependencies: + '@ethersproject/address': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/rlp': 5.8.0 + '@ethersproject/signing-key': 5.8.0 + + '@ethersproject/units@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/logger': 5.8.0 + + '@ethersproject/wallet@5.8.0': + dependencies: + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/hdnode': 5.8.0 + '@ethersproject/json-wallets': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/random': 5.8.0 + '@ethersproject/signing-key': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/wordlists': 5.8.0 + + '@ethersproject/web@5.8.0': + dependencies: + '@ethersproject/base64': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/strings': 5.8.0 + + '@ethersproject/wordlists@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/strings': 5.8.0 + + '@fastify/busboy@2.1.1': {} + + '@inquirer/external-editor@1.0.3(@types/node@22.19.17)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.17 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.2 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.4.2': + dependencies: + '@noble/hashes': 1.4.0 + + '@noble/curves@1.8.2': + dependencies: + '@noble/hashes': 1.7.2 + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.2.0': {} + + '@noble/hashes@1.4.0': {} + + '@noble/hashes@1.7.2': {} + + '@noble/hashes@1.8.0': {} + + '@noble/secp256k1@1.7.1': {} + + '@nodable/entities@2.1.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nomicfoundation/edr-darwin-arm64@0.12.0-next.23': {} + + '@nomicfoundation/edr-darwin-x64@0.12.0-next.23': {} + + '@nomicfoundation/edr-linux-arm64-gnu@0.12.0-next.23': {} + + '@nomicfoundation/edr-linux-arm64-musl@0.12.0-next.23': {} + + '@nomicfoundation/edr-linux-x64-gnu@0.12.0-next.23': {} + + '@nomicfoundation/edr-linux-x64-musl@0.12.0-next.23': {} + + '@nomicfoundation/edr-win32-x64-msvc@0.12.0-next.23': {} + + '@nomicfoundation/edr@0.12.0-next.23': + dependencies: + '@nomicfoundation/edr-darwin-arm64': 0.12.0-next.23 + '@nomicfoundation/edr-darwin-x64': 0.12.0-next.23 + '@nomicfoundation/edr-linux-arm64-gnu': 0.12.0-next.23 + '@nomicfoundation/edr-linux-arm64-musl': 0.12.0-next.23 + '@nomicfoundation/edr-linux-x64-gnu': 0.12.0-next.23 + '@nomicfoundation/edr-linux-x64-musl': 0.12.0-next.23 + '@nomicfoundation/edr-win32-x64-msvc': 0.12.0-next.23 + + '@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3))': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + ethers: 5.8.0 + hardhat: 2.28.6(typescript@5.9.3) + lodash.isequal: 4.5.0 + transitivePeerDependencies: + - supports-color + + '@nomicfoundation/slang@0.18.3': + dependencies: + '@bytecodealliance/preview2-shim': 0.17.0 + + '@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-darwin-x64@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer@0.1.2': + optionalDependencies: + '@nomicfoundation/solidity-analyzer-darwin-arm64': 0.1.2 + '@nomicfoundation/solidity-analyzer-darwin-x64': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-arm64-musl': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-x64-gnu': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.2 + '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.2 + + '@offchainlabs/upgrade-executor@1.1.0-beta.0': + dependencies: + '@openzeppelin/contracts': 4.7.3 + '@openzeppelin/contracts-upgradeable': 4.7.3 + + '@openzeppelin/contracts-upgradeable@4.7.3': {} + + '@openzeppelin/contracts-upgradeable@4.9.6': {} + + '@openzeppelin/contracts-upgradeable@5.1.0(@openzeppelin/contracts@5.1.0)': + dependencies: + '@openzeppelin/contracts': 5.1.0 + + '@openzeppelin/contracts@4.7.3': {} + + '@openzeppelin/contracts@4.8.3': {} + + '@openzeppelin/contracts@4.9.6': {} + + '@openzeppelin/contracts@5.0.2': {} + + '@openzeppelin/contracts@5.1.0': {} + + '@openzeppelin/defender-sdk-base-client@2.7.1(debug@4.4.3)': + dependencies: + '@aws-sdk/client-lambda': 3.1042.0 + amazon-cognito-identity-js: 6.3.16 + async-retry: 1.3.3 + axios: 1.16.0(debug@4.4.3) + transitivePeerDependencies: + - aws-crt + - debug + - encoding + + '@openzeppelin/defender-sdk-deploy-client@2.7.1(debug@4.4.3)': + dependencies: + '@openzeppelin/defender-sdk-base-client': 2.7.1(debug@4.4.3) + axios: 1.16.0(debug@4.4.3) + lodash: 4.18.1 + transitivePeerDependencies: + - aws-crt + - debug + - encoding + + '@openzeppelin/defender-sdk-network-client@2.7.1(debug@4.4.3)': + dependencies: + '@openzeppelin/defender-sdk-base-client': 2.7.1(debug@4.4.3) + axios: 1.16.0(debug@4.4.3) + lodash: 4.18.1 + transitivePeerDependencies: + - aws-crt + - debug + - encoding + + '@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))': + dependencies: + '@nomicfoundation/hardhat-ethers': 3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)) + '@openzeppelin/defender-sdk-base-client': 2.7.1(debug@4.4.3) + '@openzeppelin/defender-sdk-deploy-client': 2.7.1(debug@4.4.3) + '@openzeppelin/defender-sdk-network-client': 2.7.1(debug@4.4.3) + '@openzeppelin/upgrades-core': 1.44.2 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + ethereumjs-util: 7.1.5 + ethers: 5.8.0 + hardhat: 2.28.6(typescript@5.9.3) + proper-lockfile: 4.1.2 + undici: 6.25.0 + transitivePeerDependencies: + - aws-crt + - encoding + - supports-color + + '@openzeppelin/upgrades-core@1.44.2': + dependencies: + '@nomicfoundation/slang': 0.18.3 + bignumber.js: 9.3.1 + cbor: 10.0.12 + chalk: 4.1.2 + compare-versions: 6.1.1 + debug: 4.4.3(supports-color@8.1.1) + ethereumjs-util: 7.1.5 + minimatch: 9.0.9 + minimist: 1.2.8 + proper-lockfile: 4.1.2 + solidity-ast: 0.4.62 + 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@3.0.2(typescript@5.9.3)': + dependencies: + '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) + '@safe-global/types-kit': 2.0.1(typescript@5.9.3) + node-fetch: 2.7.0 + viem: 2.48.4(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@safe-global/protocol-kit@6.1.2(typescript@5.9.3)': + dependencies: + '@safe-global/safe-deployments': 1.37.56 + '@safe-global/safe-modules-deployments': 2.2.25 + '@safe-global/types-kit': 3.1.0(typescript@5.9.3) + abitype: 1.2.3(typescript@5.9.3) + semver: 7.7.4 + viem: 2.48.4(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.56': + dependencies: + semver: 7.7.4 + + '@safe-global/safe-modules-deployments@2.2.25': {} + + '@safe-global/types-kit@2.0.1(typescript@5.9.3)': + dependencies: + abitype: 1.2.3(typescript@5.9.3) + transitivePeerDependencies: + - typescript + - zod + + '@safe-global/types-kit@3.1.0(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': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.1.5': + dependencies: + '@noble/hashes': 1.2.0 + '@noble/secp256k1': 1.7.1 + '@scure/base': 1.1.9 + + '@scure/bip32@1.4.0': + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.1.1': + dependencies: + '@noble/hashes': 1.2.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@sentry/core@5.30.0': + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/minimal': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + + '@sentry/hub@5.30.0': + dependencies: + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + + '@sentry/minimal@5.30.0': + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/types': 5.30.0 + tslib: 1.14.1 + + '@sentry/node@5.30.0': + dependencies: + '@sentry/core': 5.30.0 + '@sentry/hub': 5.30.0 + '@sentry/tracing': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + cookie: 0.4.2 + https-proxy-agent: 5.0.1 + lru_map: 0.3.3 + tslib: 1.14.1 + transitivePeerDependencies: + - supports-color + + '@sentry/tracing@5.30.0': + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/minimal': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + + '@sentry/types@5.30.0': {} + + '@sentry/utils@5.30.0': + dependencies: + '@sentry/types': 5.30.0 + tslib: 1.14.1 + + '@sinclair/typebox@0.34.49': {} + + '@smithy/config-resolver@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.32': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.7': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.20': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.6.1': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.3.1': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.13': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.49': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.54': + dependencies: + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.2': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.8': + dependencies: + '@smithy/service-error-classification': 4.3.1 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.25': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.3.0': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + + '@types/bn.js@5.2.0': + dependencies: + '@types/node': 22.19.17 + + '@types/js-yaml@4.0.9': {} + + '@types/node@12.20.55': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/pbkdf2@3.1.2': + dependencies: + '@types/node': 22.19.17 + + '@types/secp256k1@4.0.7': + dependencies: + '@types/node': 22.19.17 + + '@yarnpkg/lockfile@1.1.0': {} + + abitype@1.2.3(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + adm-zip@0.4.16: {} + + aes-js@3.0.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + amazon-cognito-identity-js@6.3.16: + dependencies: + '@aws-crypto/sha256-js': 1.2.2 + buffer: 4.9.2 + fast-base64-decode: 1.0.0 + isomorphic-unfetch: 3.1.0 + js-cookie: 2.2.1 + transitivePeerDependencies: + - encoding + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + 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: + dependencies: + retry: 0.13.1 + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@1.16.0(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base64-js@1.5.1: {} + + bech32@1.1.4: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + blakejs@1.2.1: {} + + bn.js@4.12.3: {} + + bn.js@5.2.3: {} + + bowser@2.14.1: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: {} + + browser-stdout@1.3.1: {} + + browserify-aes@1.2.0: + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.7 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + bs58check@2.1.2: + dependencies: + bs58: 4.0.1 + create-hash: 1.2.0 + safe-buffer: 5.2.1 + + buffer-from@1.1.2: {} + + buffer-xor@1.0.3: {} + + buffer@4.9.2: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + isarray: 1.0.0 + + bufio@1.2.3: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + cbor@10.0.12: + dependencies: + nofilter: 3.1.0 + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@2.1.1: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + ci-info@2.0.0: {} + + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + clean-stack@2.2.0: {} + + cli-boxes@2.2.1: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + command-exists@1.2.9: {} + + commander@8.3.0: {} + + compare-versions@6.1.1: {} + + concat-map@0.0.1: {} + + cookie@0.4.2: {} + + core-util-is@1.0.3: {} + + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-hmac@1.1.7: + dependencies: + cipher-base: 1.0.7 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dataloader@1.4.0: {} + + dateformat@4.6.3: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@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/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8 + hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 + multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + solidity-linked-list: 6.5.0 + transitivePeerDependencies: + - '@nomicfoundation/hardhat-ethers' + - '@nomicfoundation/hardhat-verify' + - '@types/node' + - aws-crt + - bufferutil + - encoding + - ethers + - hardhat + - supports-color + - utf-8-validate + + detect-indent@6.1.0: {} + + diff@5.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + env-paths@2.2.1: {} + + era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-visitor-keys@4.2.1: {} + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + ethereum-cryptography@0.1.3: + dependencies: + '@types/pbkdf2': 3.1.2 + '@types/secp256k1': 4.0.7 + blakejs: 1.2.1 + browserify-aes: 1.2.0 + bs58check: 2.1.2 + create-hash: 1.2.0 + create-hmac: 1.1.7 + hash.js: 1.1.7 + keccak: 3.0.4 + pbkdf2: 3.1.5 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + scrypt-js: 3.0.1 + secp256k1: 4.0.4 + setimmediate: 1.0.5 + + ethereum-cryptography@1.2.0: + dependencies: + '@noble/hashes': 1.2.0 + '@noble/secp256k1': 1.7.1 + '@scure/bip32': 1.1.5 + '@scure/bip39': 1.1.1 + + ethereum-cryptography@2.2.1: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + + ethereumjs-util@7.1.5: + dependencies: + '@types/bn.js': 5.2.0 + bn.js: 5.2.3 + create-hash: 1.2.0 + ethereum-cryptography: 0.1.3 + rlp: 2.2.7 + + ethers@5.8.0: + dependencies: + '@ethersproject/abi': 5.8.0 + '@ethersproject/abstract-provider': 5.8.0 + '@ethersproject/abstract-signer': 5.8.0 + '@ethersproject/address': 5.8.0 + '@ethersproject/base64': 5.8.0 + '@ethersproject/basex': 5.8.0 + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@ethersproject/contracts': 5.8.0 + '@ethersproject/hash': 5.8.0 + '@ethersproject/hdnode': 5.8.0 + '@ethersproject/json-wallets': 5.8.0 + '@ethersproject/keccak256': 5.8.0 + '@ethersproject/logger': 5.8.0 + '@ethersproject/networks': 5.8.0 + '@ethersproject/pbkdf2': 5.8.0 + '@ethersproject/properties': 5.8.0 + '@ethersproject/providers': 5.8.0 + '@ethersproject/random': 5.8.0 + '@ethersproject/rlp': 5.8.0 + '@ethersproject/sha2': 5.8.0 + '@ethersproject/signing-key': 5.8.0 + '@ethersproject/solidity': 5.8.0 + '@ethersproject/strings': 5.8.0 + '@ethersproject/transactions': 5.8.0 + '@ethersproject/units': 5.8.0 + '@ethersproject/wallet': 5.8.0 + '@ethersproject/web': 5.8.0 + '@ethersproject/wordlists': 5.8.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + eventemitter3@5.0.1: {} + + evp_bytestokey@1.0.3: + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + + extendable-error@0.1.7: {} + + fast-base64-decode@1.0.0: {} + + fast-copy@4.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fast-xml-builder@1.1.8: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.8 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-yarn-workspace-root@2.0.0: + dependencies: + micromatch: 4.0.8 + + flat@5.0.2: {} + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3(supports-color@8.1.1) + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fp-ts@1.19.3: {} + + fraction.js@5.3.4: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#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': 3.0.2(typescript@5.9.3) + '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) + '@safe-global/types-kit': 2.0.1(typescript@5.9.3) + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8 + hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 + multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + solidity-linked-list: 6.5.0 + viem: 2.48.4(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: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + globals@14.0.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + hardhat@2.28.6(typescript@5.9.3): + dependencies: + '@ethereumjs/util': 9.1.0 + '@ethersproject/abi': 5.8.0 + '@nomicfoundation/edr': 0.12.0-next.23 + '@nomicfoundation/solidity-analyzer': 0.1.2 + '@sentry/node': 5.30.0 + adm-zip: 0.4.16 + aggregate-error: 3.1.0 + ansi-escapes: 4.3.2 + boxen: 5.1.2 + chokidar: 4.0.3 + ci-info: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + enquirer: 2.4.1 + env-paths: 2.2.1 + ethereum-cryptography: 1.2.0 + find-up: 5.0.0 + fp-ts: 1.19.3 + fs-extra: 7.0.1 + immutable: 4.3.8 + io-ts: 1.10.4 + json-stream-stringify: 3.1.6 + keccak: 3.0.4 + lodash: 4.18.1 + micro-eth-signer: 0.14.0 + mnemonist: 0.38.5 + mocha: 10.8.2 + p-map: 4.0.0 + picocolors: 1.1.1 + raw-body: 2.5.3 + resolve: 1.17.0 + semver: 6.3.1 + solc: 0.8.26(debug@4.4.3) + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.16 + tsort: 0.0.1 + undici: 5.29.0 + uuid: 8.3.2 + ws: 7.5.10 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8: {} + + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + human-id@4.1.3: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + immutable@4.3.8: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + io-ts@1.10.4: + dependencies: + fp-ts: 1.19.3 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-callable@1.2.7: {} + + is-ci@2.0.0: + dependencies: + ci-info: 2.0.0 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-unicode-supported@0.1.0: {} + + is-windows@1.0.2: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isomorphic-unfetch@3.1.0: + dependencies: + node-fetch: 2.7.0 + unfetch: 4.2.0 + transitivePeerDependencies: + - encoding + + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + + joycon@3.1.1: {} + + js-cookie@2.2.1: {} + + js-sha3@0.8.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stream-stringify@3.1.6: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keccak@3.0.4: + dependencies: + node-addon-api: 2.0.2 + node-gyp-build: 4.8.4 + readable-stream: 3.6.2 + + klaw-sync@6.0.0: + dependencies: + graceful-fs: 4.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 - '@adraffy/ens-normalize@1.11.1': {} + lodash.isequal@4.5.0: {} - '@noble/ciphers@1.3.0': {} + lodash.startcase@4.4.0: {} - '@noble/curves@1.9.1': + lodash@4.18.1: {} + + log-symbols@4.1.0: dependencies: - '@noble/hashes': 1.8.0 + chalk: 4.1.2 + is-unicode-supported: 0.1.0 - '@noble/hashes@1.8.0': {} + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 - '@pinojs/redact@0.4.0': {} + lru_map@0.3.3: {} - '@scure/base@1.2.6': {} + math-intrinsics@1.1.0: {} - '@scure/bip32@1.7.0': + md5.js@1.3.5: dependencies: - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 + hash-base: 3.1.2 + inherits: 2.0.4 + safe-buffer: 5.2.1 - '@scure/bip39@1.6.0': + memorystream@0.3.1: {} + + merge2@1.4.1: {} + + micro-eth-signer@0.14.0: + dependencies: + '@noble/curves': 1.8.2 + '@noble/hashes': 1.7.2 + micro-packed: 0.7.3 + + micro-packed@0.7.3: dependencies: - '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@sinclair/typebox@0.34.49': {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 - '@types/js-yaml@4.0.9': {} + mime-db@1.52.0: {} - '@types/node@22.19.17': + mime-types@2.1.35: dependencies: - undici-types: 6.21.0 + mime-db: 1.52.0 - abitype@1.2.3(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 + minimalistic-assert@1.0.1: {} - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 + minimalistic-crypto-utils@1.0.1: {} - ajv@8.18.0: + minimatch@3.1.5: dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - argparse@2.0.1: {} + brace-expansion: 1.1.14 - atomic-sleep@1.0.0: {} + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 - colorette@2.0.20: {} + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 - dateformat@4.6.3: {} + minimist@1.2.8: {} - end-of-stream@1.4.5: + mnemonist@0.38.5: dependencies: - once: 1.4.0 + obliterator: 2.0.5 - eventemitter3@5.0.1: {} + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.2 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.1 + log-symbols: 4.1.0 + minimatch: 5.1.9 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.0 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 - fast-copy@4.0.3: {} + mri@1.2.0: {} - fast-deep-equal@3.1.3: {} + ms@2.1.3: {} - fast-safe-stringify@2.1.1: {} + multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012: {} - fast-uri@3.1.0: {} + nice-try@1.0.5: {} - fraction.js@5.3.4: {} + node-addon-api@2.0.2: {} - help-me@5.0.0: {} + node-addon-api@5.1.0: {} - isows@1.0.7(ws@8.18.3): + node-fetch@2.7.0: dependencies: - ws: 8.18.3 + whatwg-url: 5.0.0 - joycon@3.1.1: {} + node-gyp-build@4.8.4: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 + nofilter@3.1.0: {} - json-schema-traverse@1.0.0: {} + normalize-path@3.0.0: {} - minimist@1.2.8: {} + obliterator@2.0.5: {} on-exit-leak-free@2.1.2: {} @@ -404,6 +5179,15 @@ snapshots: dependencies: wrappy: 1.0.2 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + os-tmpdir@1.0.2: {} + + outdent@0.5.0: {} + ox@0.14.20(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -419,6 +5203,92 @@ snapshots: transitivePeerDependencies: - zod + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + patch-package@6.5.1: + dependencies: + '@yarnpkg/lockfile': 1.1.0 + chalk: 4.1.2 + cross-spawn: 6.0.6 + find-yarn-workspace-root: 2.0.0 + fs-extra: 9.1.0 + is-ci: 2.0.0 + klaw-sync: 6.0.0 + minimist: 1.2.8 + open: 7.4.2 + rimraf: 2.7.1 + semver: 5.7.2 + slash: 2.0.0 + tmp: 0.0.33 + yaml: 1.10.3 + + path-exists@4.0.0: {} + + path-expression-matcher@1.5.0: {} + + path-is-absolute@1.0.1: {} + + path-key@2.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathval@1.1.1: {} + + pbkdf2@3.1.5: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -455,39 +5325,340 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + possible-typed-array-names@1.1.0: {} + + prettier@2.8.8: {} + + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + 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: {} + quick-format-unescaped@4.0.4: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + real-require@0.2.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.17.0: + dependencies: + path-parse: 1.0.7 + + retry@0.12.0: {} + + retry@0.13.1: {} + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + + rlp@2.2.7: + dependencies: + bn.js: 5.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + + scrypt-js@3.0.1: {} + + secp256k1@4.0.4: + dependencies: + elliptic: 6.6.1 + node-addon-api: 5.1.0 + node-gyp-build: 4.8.4 + secure-json-parse@4.1.0: {} + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: {} + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@2.0.0: {} + + slash@3.0.0: {} + + solady@0.0.182: {} + + solc@0.8.26(debug@4.4.3): + dependencies: + command-exists: 1.2.9 + commander: 8.3.0 + follow-redirects: 1.16.0(debug@4.4.3) + js-sha3: 0.8.0 + memorystream: 0.3.1 + semver: 5.7.2 + tmp: 0.0.33 + transitivePeerDependencies: + - debug + + solidity-ast@0.4.62: {} + + solidity-linked-list@6.5.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + strnum@2.2.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + term-size@2.2.1: {} + thread-stream@4.0.0: dependencies: real-require: 0.2.0 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsort@0.0.1: {} + + type-detect@4.1.0: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typescript@5.9.3: {} undici-types@6.21.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@6.25.0: {} + + unfetch@4.2.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + viem@2.48.4(typescript@5.9.3): dependencies: '@noble/curves': 1.9.1 @@ -505,6 +5676,72 @@ snapshots: - utf-8-validate - zod + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + workerpool@6.5.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} + ws@7.5.10: {} + + ws@8.18.0: {} + ws@8.18.3: {} + + y18n@5.0.8: {} + + yaml@1.10.3: {} + + yargs-parser@20.2.9: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yocto-queue@0.1.0: {} diff --git a/market-maker/scripts/sync-abi.sh b/market-maker/scripts/sync-abi.sh deleted file mode 100755 index 47f8ae8..0000000 --- a/market-maker/scripts/sync-abi.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Copy canonical ABIs from the three contract repos into market-maker. -# -# The three sources of truth: -# collateral-margin/contracts → CollateralVault, PortfolioMarginEngine -# ../perps/contracts → HashPowerPerpsDEX -# ../futures-marketplace/contracts → Futures -# -# The MM never imports from those repos directly — running this script (via -# `pnpm pretest`) keeps a vendored copy under src/abi. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -CM_CONTRACTS="$(cd "$ROOT/../contracts" && pwd)" -PERPS_CONTRACTS="$(cd "$ROOT/../../perps/contracts" 2>/dev/null && pwd || true)" -FUTURES_CONTRACTS="$(cd "$ROOT/../../futures-marketplace/contracts" 2>/dev/null && pwd || true)" - -cd "$CM_CONTRACTS" && pnpm hardhat compile >/dev/null -[[ -n "${PERPS_CONTRACTS:-}" ]] && (cd "$PERPS_CONTRACTS" && pnpm hardhat compile >/dev/null) -[[ -n "${FUTURES_CONTRACTS:-}" ]] && (cd "$FUTURES_CONTRACTS" && pnpm hardhat compile >/dev/null) - -mkdir -p "$ROOT/src/abi" - -cp "$CM_CONTRACTS/abi/CollateralVault.ts" "$ROOT/src/abi/CollateralVault.ts" -cp "$CM_CONTRACTS/abi/PortfolioMarginEngine.ts" "$ROOT/src/abi/PortfolioMarginEngine.ts" - -if [[ -n "${PERPS_CONTRACTS:-}" ]]; then - cp "$PERPS_CONTRACTS/abi/HashPowerPerpsDEX.ts" "$ROOT/src/abi/HashPowerPerpsDEX.ts" - cp "$PERPS_CONTRACTS/abi/Multicall3.ts" "$ROOT/src/abi/Multicall3.ts" -fi - -if [[ -n "${FUTURES_CONTRACTS:-}" ]]; then - cp "$FUTURES_CONTRACTS/abi/Futures.ts" "$ROOT/src/abi/Futures.ts" -fi - -echo "ABIs synced to $ROOT/src/abi/" diff --git a/market-maker/src/abi/CollateralVault.ts b/market-maker/src/abi/CollateralVault.ts deleted file mode 100644 index 10ff645..0000000 --- a/market-maker/src/abi/CollateralVault.ts +++ /dev/null @@ -1,940 +0,0 @@ -export const CollateralVaultAbi = [ - { - "inputs": [], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "target", - "type": "address" - } - ], - "name": "AddressEmptyCode", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "ERC1967InvalidImplementation", - "type": "error" - }, - { - "inputs": [], - "name": "ERC1967NonPayable", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "allowance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientAllowance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "balance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientBalance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "approver", - "type": "address" - } - ], - "name": "ERC20InvalidApprover", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "receiver", - "type": "address" - } - ], - "name": "ERC20InvalidReceiver", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "ERC20InvalidSender", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - } - ], - "name": "ERC20InvalidSpender", - "type": "error" - }, - { - "inputs": [], - "name": "FailedCall", - "type": "error" - }, - { - "inputs": [], - "name": "FunctionDisabled", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidInitialization", - "type": "error" - }, - { - "inputs": [], - "name": "MarginBreach", - "type": "error" - }, - { - "inputs": [], - "name": "NotAuthorized", - "type": "error" - }, - { - "inputs": [], - "name": "NotInitializing", - "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": [ - { - "internalType": "address", - "name": "token", - "type": "address" - } - ], - "name": "SafeERC20FailedOperation", - "type": "error" - }, - { - "inputs": [], - "name": "UUPSUnauthorizedCallContext", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "slot", - "type": "bytes32" - } - ], - "name": "UUPSUnsupportedProxiableUUID", - "type": "error" - }, - { - "inputs": [], - "name": "ZeroAddress", - "type": "error" - }, - { - "inputs": [], - "name": "ZeroAmount", - "type": "error" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Approval", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "caller", - "type": "address" - }, - { - "indexed": false, - "internalType": "bool", - "name": "authorized", - "type": "bool" - } - ], - "name": "AuthorizedCallerSet", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "user", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "Deposited", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" - } - ], - "name": "Initialized", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "source", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "InsuranceFundDeposited", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "recipient", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "InsuranceFundWithdrawn", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "marginEngine", - "type": "address" - } - ], - "name": "MarginEngineSet", - "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": true, - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Transfer", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "Upgraded", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "user", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "address", - "name": "recipient", - "type": "address" - } - ], - "name": "Withdrawn", - "type": "event" - }, - { - "inputs": [], - "name": "INSURANCE_FUND_ADDR", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "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": "address", - "name": "", - "type": "address" - }, - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "allowance", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "approve", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "authorizedCallers", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "balanceOf", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "collateralToken", - "outputs": [ - { - "internalType": "contract IERC20", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "decimals", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "deposit", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "recipient", - "type": "address" - }, - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "depositFor", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "depositInsuranceFund", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_collateralToken", - "type": "address" - } - ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "insuranceFundBalance", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "internalTransfer", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "internalTransferWithMarginCheck", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "marginEngine", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "name", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "owner", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "proxiableUUID", - "outputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "caller", - "type": "address" - }, - { - "internalType": "bool", - "name": "authorized", - "type": "bool" - } - ], - "name": "setAuthorizedCaller", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_marginEngine", - "type": "address" - } - ], - "name": "setMarginEngine", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "symbol", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "totalSupply", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "transfer", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "transferFrom", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "transferOwnership", - "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": [ - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "withdraw", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "recipient", - "type": "address" - }, - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "withdrawInsuranceFund", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "recipient", - "type": "address" - }, - { - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "withdrawTo", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - } -] as const; diff --git a/market-maker/src/abi/Futures.ts b/market-maker/src/abi/Futures.ts deleted file mode 100644 index dc36082..0000000 --- a/market-maker/src/abi/Futures.ts +++ /dev/null @@ -1,1545 +0,0 @@ -export const FuturesAbi = [ - { - "inputs": [ - { - "internalType": "contract ICollateralVault", - "name": "_collateralVault", - "type": "address" - } - ], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "target", - "type": "address" - } - ], - "name": "AddressEmptyCode", - "type": "error" - }, - { - "inputs": [], - "name": "CollateralTokenMismatch", - "type": "error" - }, - { - "inputs": [], - "name": "DeliveryDateExpired", - "type": "error" - }, - { - "inputs": [], - "name": "DeliveryDateNotAvailable", - "type": "error" - }, - { - "inputs": [], - "name": "DeliveryDateShouldBeInTheFuture", - "type": "error" - }, - { - "inputs": [], - "name": "DeliveryNotFinishedYet", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "ERC1967InvalidImplementation", - "type": "error" - }, - { - "inputs": [], - "name": "ERC1967NonPayable", - "type": "error" - }, - { - "inputs": [], - "name": "FailedCall", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "reserve", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "required", - "type": "uint256" - } - ], - "name": "InsufficientContractReserve", - "type": "error" - }, - { - "inputs": [], - "name": "InsufficientMarginBalance", - "type": "error" - }, - { - "inputs": [], - "name": "InsuranceFundNotConfigured", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidInitialization", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidOracle", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidPrice", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidQty", - "type": "error" - }, - { - "inputs": [], - "name": "MaxOrdersPerParticipantReached", - "type": "error" - }, - { - "inputs": [], - "name": "NotInitializing", - "type": "error" - }, - { - "inputs": [], - "name": "NothingToWithdraw", - "type": "error" - }, - { - "inputs": [], - "name": "OnlyPositionBuyer", - "type": "error" - }, - { - "inputs": [], - "name": "OnlyValidator", - "type": "error" - }, - { - "inputs": [], - "name": "OnlyValidatorOrPositionParticipant", - "type": "error" - }, - { - "inputs": [], - "name": "OracleStale", - "type": "error" - }, - { - "inputs": [], - "name": "OrderNotBelongToSender", - "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": "PositionAlreadyPaid", - "type": "error" - }, - { - "inputs": [], - "name": "PositionDeliveryExpired", - "type": "error" - }, - { - "inputs": [], - "name": "PositionDeliveryNotStartedYet", - "type": "error" - }, - { - "inputs": [], - "name": "PositionDestURLNotSet", - "type": "error" - }, - { - "inputs": [], - "name": "PositionNotExists", - "type": "error" - }, - { - "inputs": [], - "name": "TransferDisabled", - "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": "ZeroAddress", - "type": "error" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "account", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "BadDebt", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" - } - ], - "name": "Initialized", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "participant", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "liquidator", - "type": "address" - }, - { - "indexed": false, - "internalType": "int256", - "name": "reclaimedMargin", - "type": "int256" - }, - { - "indexed": false, - "internalType": "int256", - "name": "realizedPnl", - "type": "int256" - } - ], - "name": "Liquidation", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "orderId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "participant", - "type": "address" - } - ], - "name": "OrderClosed", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "orderId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "participant", - "type": "address" - }, - { - "indexed": false, - "internalType": "string", - "name": "destURL", - "type": "string" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "pricePerDay", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "deliveryAt", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "bool", - "name": "isBuy", - "type": "bool" - } - ], - "name": "OrderCreated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "orderFee", - "type": "uint256" - } - ], - "name": "OrderFeeUpdated", - "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": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - } - ], - "name": "PositionClosed", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "seller", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "buyer", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "sellPricePerDay", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "buyPricePerDay", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "deliveryAt", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "string", - "name": "destURL", - "type": "string" - }, - { - "indexed": false, - "internalType": "bytes32", - "name": "orderId", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "bytes32", - "name": "takerOrderId", - "type": "bytes32" - } - ], - "name": "PositionCreated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "closedBy", - "type": "address" - } - ], - "name": "PositionDeliveryClosed", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "participant", - "type": "address" - }, - { - "indexed": false, - "internalType": "int256", - "name": "pnl", - "type": "int256" - } - ], - "name": "PositionExited", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - } - ], - "name": "PositionPaid", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - } - ], - "name": "PositionPaymentReceived", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "Upgraded", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "string", - "name": "validatorURL", - "type": "string" - } - ], - "name": "ValidatorURLUpdated", - "type": "event" - }, - { - "inputs": [], - "name": "BREACH_PENALTY_DECIMALS", - "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", - "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": [], - "name": "breachPenaltyRatePerDay", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_positionId", - "type": "bytes32" - }, - { - "internalType": "bool", - "name": "_blameSeller", - "type": "bool" - } - ], - "name": "closeDelivery", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "collateralVault", - "outputs": [ - { - "internalType": "contract ICollateralVault", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "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": "_deliveryDate", - "type": "uint256" - }, - { - "internalType": "string", - "name": "_destURL", - "type": "string" - }, - { - "internalType": "int8", - "name": "_qty", - "type": "int8" - } - ], - "name": "createOrder", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "deliveryDurationDays", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "deliveryIntervalDays", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32[]", - "name": "_positionIds", - "type": "bytes32[]" - } - ], - "name": "depositDeliveryPayment", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "positionId", - "type": "bytes32" - } - ], - "name": "depositDeliveryPaymentV2", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "firstFutureDeliveryDate", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "futureDeliveryDatesCount", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getCollateralDeficit", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getDeliveryDates", - "outputs": [ - { - "internalType": "uint256[]", - "name": "", - "type": "uint256[]" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getFuturesOrderMargin", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getFuturesUnrealizedPnl", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getMarketPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getMinMargin", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_entryPricePerDay", - "type": "uint256" - }, - { - "internalType": "int256", - "name": "_qty", - "type": "int256" - } - ], - "name": "getMinMarginForPosition", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getNetPositionDelta", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_orderId", - "type": "bytes32" - } - ], - "name": "getOrderById", - "outputs": [ - { - "components": [ - { - "internalType": "bool", - "name": "isBuy", - "type": "bool" - }, - { - "internalType": "address", - "name": "participant", - "type": "address" - }, - { - "internalType": "string", - "name": "destURL", - "type": "string" - }, - { - "internalType": "uint256", - "name": "pricePerDay", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "deliveryAt", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "createdAt", - "type": "uint256" - } - ], - "internalType": "struct Futures.Order", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getOrderFee", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_positionId", - "type": "bytes32" - } - ], - "name": "getPositionById", - "outputs": [ - { - "components": [ - { - "internalType": "address", - "name": "seller", - "type": "address" - }, - { - "internalType": "address", - "name": "buyer", - "type": "address" - }, - { - "internalType": "string", - "name": "destURL", - "type": "string" - }, - { - "internalType": "uint256", - "name": "sellPricePerDay", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "buyPricePerDay", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "deliveryAt", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "createdAt", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "paid", - "type": "bool" - } - ], - "internalType": "struct Futures.Position", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - }, - { - "internalType": "uint256", - "name": "_deliveryDate", - "type": "uint256" - } - ], - "name": "getPositionsByParticipantDeliveryDate", - "outputs": [ - { - "internalType": "bytes32[]", - "name": "", - "type": "bytes32[]" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "hashpriceScalingDivisor", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "hashrateOracle", - "outputs": [ - { - "internalType": "contract AggregatorV3Interface", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract AggregatorV3Interface", - "name": "_hashrateOracle", - "type": "address" - }, - { - "internalType": "address", - "name": "_validatorAddress", - "type": "address" - }, - { - "internalType": "uint8", - "name": "_liquidationMarginPercent", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "_speedHps", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_minimumPriceIncrement", - "type": "uint256" - }, - { - "internalType": "uint8", - "name": "_deliveryDurationDays", - "type": "uint8" - }, - { - "internalType": "uint8", - "name": "_deliveryIntervalDays", - "type": "uint8" - }, - { - "internalType": "uint8", - "name": "_futureDeliveryDatesCount", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "_firstFutureDeliveryDate", - "type": "uint256" - } - ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "liquidationMarginPercent", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "marginCall", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "marginEngine", - "outputs": [ - { - "internalType": "contract IPortfolioMarginEngine", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "minimumPriceIncrement", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes[]", - "name": "data", - "type": "bytes[]" - } - ], - "name": "multicall", - "outputs": [ - { - "internalType": "bytes[]", - "name": "results", - "type": "bytes[]" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "orderFee", - "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": "proxiableUUID", - "outputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "removeOutdatedOrdersForParticipant", - "outputs": [ - { - "internalType": "uint256", - "name": "count", - "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": "uint256", - "name": "_breachPenaltyRatePerDay", - "type": "uint256" - } - ], - "name": "setBreachPenaltyRatePerDay", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_address", - "type": "address" - }, - { - "internalType": "uint8", - "name": "_feeDiscountPercent", - "type": "uint8" - } - ], - "name": "setFeeDiscountPercent", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint8", - "name": "_futureDeliveryDatesCount", - "type": "uint8" - } - ], - "name": "setFutureDeliveryDatesCount", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint8", - "name": "_liquidationMarginPercent", - "type": "uint8" - } - ], - "name": "setLiquidationMarginPercent", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_marginEngine", - "type": "address" - } - ], - "name": "setMarginEngine", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "addr", - "type": "address" - } - ], - "name": "setOracle", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_orderFee", - "type": "uint256" - } - ], - "name": "setOrderFee", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_validatorAddress", - "type": "address" - } - ], - "name": "setValidatorAddress", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "string", - "name": "_validatorURL", - "type": "string" - } - ], - "name": "setValidatorURL", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "speedHps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "transferOwnership", - "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": "validatorAddress", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "validatorURL", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "withdrawCollectedFees", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_deliveryDate", - "type": "uint256" - } - ], - "name": "withdrawDeliveryPayment", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - } -] as const; diff --git a/market-maker/src/abi/FuturesMmExtensions.ts b/market-maker/src/abi/FuturesMmExtensions.ts deleted file mode 100644 index 7acc907..0000000 --- a/market-maker/src/abi/FuturesMmExtensions.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Hand-authored ABI for the views added to `Futures.sol` for the off-chain - * market maker. These match the entries that `pnpm sync-abi` will eventually - * fold into `Futures.ts`; until then they live here so the adapter compiles - * against the new contract. - * - * Keep in sync with `Futures.sol#getOrderIds / getPositionIds / getBidPrices / - * getAskPrices / getQuantityAtPrice / closeOrder / MAX_ORDER_QTY`. - */ -export const FuturesMmExtensionsAbi = [ - { - inputs: [], - name: "MAX_ORDER_QTY", - outputs: [{ internalType: "int8", name: "", type: "int8" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "address", name: "_participant", type: "address" }], - name: "getOrderIds", - outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "address", name: "_participant", type: "address" }], - name: "getPositionIds", - outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, - { internalType: "uint256", name: "_maxLevels", type: "uint256" }, - ], - name: "getBidPrices", - outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "uint256", name: "_deliveryDate", type: "uint256" }, - { internalType: "uint256", name: "_maxLevels", type: "uint256" }, - ], - name: "getAskPrices", - outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "uint256", name: "_deliveryDate", 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: "bytes32", name: "_orderId", type: "bytes32" }], - name: "closeOrder", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, -] as const; diff --git a/market-maker/src/abi/HashPowerPerpsDEX.ts b/market-maker/src/abi/HashPowerPerpsDEX.ts deleted file mode 100644 index c14b74d..0000000 --- a/market-maker/src/abi/HashPowerPerpsDEX.ts +++ /dev/null @@ -1,1474 +0,0 @@ -export const HashPowerPerpsDEXAbi = [ - { - "inputs": [ - { - "internalType": "uint256", - "name": "_minimumPriceIncrement", - "type": "uint256" - } - ], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "target", - "type": "address" - } - ], - "name": "AddressEmptyCode", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "ERC1967InvalidImplementation", - "type": "error" - }, - { - "inputs": [], - "name": "ERC1967NonPayable", - "type": "error" - }, - { - "inputs": [], - "name": "FailedCall", - "type": "error" - }, - { - "inputs": [], - "name": "InsufficientCollateral", - "type": "error" - }, - { - "inputs": [], - "name": "InsufficientMargin", - "type": "error" - }, - { - "inputs": [], - "name": "InsufficientReservePool", - "type": "error" - }, - { - "inputs": [], - "name": "InsuranceFundNotConfigured", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidFundingParameters", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidInitialization", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidMarginPercent", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidOracle", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidPrice", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidSize", - "type": "error" - }, - { - "inputs": [], - "name": "MaxOrdersPerParticipantReached", - "type": "error" - }, - { - "inputs": [], - "name": "MaxPriceLevelsReached", - "type": "error" - }, - { - "inputs": [], - "name": "NotInitializing", - "type": "error" - }, - { - "inputs": [], - "name": "NotLiquidatable", - "type": "error" - }, - { - "inputs": [], - "name": "OracleStale", - "type": "error" - }, - { - "inputs": [], - "name": "OrderMarginTooLow", - "type": "error" - }, - { - "inputs": [], - "name": "OrderNotBelongToSender", - "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": "UUPSUnauthorizedCallContext", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "slot", - "type": "bytes32" - } - ], - "name": "UUPSUnsupportedProxiableUUID", - "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": "uint256", - "name": "maxBps", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "period", - "type": "uint256" - } - ], - "name": "FundingParametersUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "user", - "type": "address" - }, - { - "indexed": false, - "internalType": "int256", - "name": "amount", - "type": "int256" - } - ], - "name": "FundingSettled", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "int256", - "name": "fundingRate", - "type": "int256" - }, - { - "indexed": false, - "internalType": "int256", - "name": "cumulativeFundingPerUnit", - "type": "int256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "timestamp", - "type": "uint256" - } - ], - "name": "FundingUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" - } - ], - "name": "Initialized", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "newLiquidationFee", - "type": "uint256" - } - ], - "name": "LiquidationFeeUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint8", - "name": "newMaintenanceMarginPercent", - "type": "uint8" - } - ], - "name": "MaintenanceMarginPercentUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint8", - "name": "newMarginPercent", - "type": "uint8" - } - ], - "name": "MarginPercentUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "int16", - "name": "newTakerFeeBps", - "type": "int16" - }, - { - "indexed": false, - "internalType": "int16", - "name": "newMakerFeeBps", - "type": "int16" - } - ], - "name": "MatchFeeUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "newMinimumMarginPerOrder", - "type": "uint256" - } - ], - "name": "MinimumMarginPerOrderUpdated", - "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" - } - ], - "name": "OrderCreated", - "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": "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": true, - "internalType": "address", - "name": "user", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "liquidator", - "type": "address" - }, - { - "indexed": false, - "internalType": "int256", - "name": "positionSize", - "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": "implementation", - "type": "address" - } - ], - "name": "Upgraded", - "type": "event" - }, - { - "inputs": [], - "name": "FUNDING_DECIMALS", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "MAX_ORDERS_PER_PARTICIPANT", - "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": "address", - "name": "account", - "type": "address" - } - ], - "name": "balanceOf", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_orderId", - "type": "bytes32" - } - ], - "name": "cancelOrder", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "collateralToken", - "outputs": [ - { - "internalType": "contract IERC20", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_price", - "type": "uint256" - }, - { - "internalType": "int256", - "name": "_quantity", - "type": "int256" - } - ], - "name": "createOrder", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "cumulativeFundingPerUnit", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "decimals", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "fundingPeriod", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "fundingRateMaxBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getBestAskPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getBestBidPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getInitialMargin", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getMaintenanceMargin", - "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": "struct HashPowerPerpsDEX.Order", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_maxLevels", - "type": "uint256" - } - ], - "name": "getOrderBookPrices", - "outputs": [ - { - "internalType": "uint256[]", - "name": "bidPrices", - "type": "uint256[]" - }, - { - "internalType": "uint256[]", - "name": "askPrices", - "type": "uint256[]" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getOrderMargin", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getPendingFunding", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_price", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "_isBid", - "type": "bool" - } - ], - "name": "getQuantityAtPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "totalQuantity", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getRequiredMargin", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getUnrealizedPnl", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getUserOrders", - "outputs": [ - { - "internalType": "bytes32[]", - "name": "", - "type": "bytes32[]" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getUserPosition", - "outputs": [ - { - "components": [ - { - "internalType": "int256", - "name": "netQuantity", - "type": "int256" - }, - { - "internalType": "uint256", - "name": "aggregatedEntryPrice", - "type": "uint256" - } - ], - "internalType": "struct HashPowerPerpsDEX.Position", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getUsersWithPositions", - "outputs": [ - { - "internalType": "address[]", - "name": "", - "type": "address[]" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract AggregatorV3Interface", - "name": "_priceOracle", - "type": "address" - }, - { - "internalType": "contract ICollateralVault", - "name": "_vault", - "type": "address" - } - ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract ICollateralVault", - "name": "_vault", - "type": "address" - }, - { - "internalType": "contract IPortfolioMarginEngine", - "name": "_pm", - "type": "address" - } - ], - "name": "initializeV2", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "isLiquidatable", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "lastFundingUpdateTime", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address[]", - "name": "_users", - "type": "address[]" - } - ], - "name": "liquidateBatch", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "liquidationFee", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "maintenanceMarginPercent", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "makerFeeBps", - "outputs": [ - { - "internalType": "int16", - "name": "", - "type": "int16" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "marginPercent", - "outputs": [ - { - "internalType": "uint8", - "name": "", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "minimumMarginPerOrder", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "minimumPriceIncrement", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes[]", - "name": "data", - "type": "bytes[]" - } - ], - "name": "multicall", - "outputs": [ - { - "internalType": "bytes[]", - "name": "results", - "type": "bytes[]" - } - ], - "stateMutability": "nonpayable", - "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": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "resetState", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_fundingRateMaxBps", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_fundingPeriod", - "type": "uint256" - } - ], - "name": "setFundingParameters", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_liquidationFee", - "type": "uint256" - } - ], - "name": "setLiquidationFee", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "int16", - "name": "_takerFeeBps", - "type": "int16" - }, - { - "internalType": "int16", - "name": "_makerFeeBps", - "type": "int16" - } - ], - "name": "setMatchFee", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_minimumMarginPerOrder", - "type": "uint256" - } - ], - "name": "setMinimumMarginPerOrder", - "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": "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": [], - "name": "updateFunding", - "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" - } -] as const; diff --git a/market-maker/src/abi/Multicall3.ts b/market-maker/src/abi/Multicall3.ts deleted file mode 100644 index a82897c..0000000 --- a/market-maker/src/abi/Multicall3.ts +++ /dev/null @@ -1,440 +0,0 @@ -export const Multicall3Abi = [ - { - "inputs": [ - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "aggregate", - "outputs": [ - { - "internalType": "uint256", - "name": "blockNumber", - "type": "uint256" - }, - { - "internalType": "bytes[]", - "name": "returnData", - "type": "bytes[]" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bool", - "name": "allowFailure", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call3[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "aggregate3", - "outputs": [ - { - "components": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "returnData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Result[]", - "name": "returnData", - "type": "tuple[]" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bool", - "name": "allowFailure", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call3Value[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "aggregate3Value", - "outputs": [ - { - "components": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "returnData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Result[]", - "name": "returnData", - "type": "tuple[]" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "blockAndAggregate", - "outputs": [ - { - "internalType": "uint256", - "name": "blockNumber", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "blockHash", - "type": "bytes32" - }, - { - "components": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "returnData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Result[]", - "name": "returnData", - "type": "tuple[]" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [], - "name": "getBasefee", - "outputs": [ - { - "internalType": "uint256", - "name": "basefee", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "blockNumber", - "type": "uint256" - } - ], - "name": "getBlockHash", - "outputs": [ - { - "internalType": "bytes32", - "name": "blockHash", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getBlockNumber", - "outputs": [ - { - "internalType": "uint256", - "name": "blockNumber", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getChainId", - "outputs": [ - { - "internalType": "uint256", - "name": "chainid", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getCurrentBlockCoinbase", - "outputs": [ - { - "internalType": "address", - "name": "coinbase", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getCurrentBlockDifficulty", - "outputs": [ - { - "internalType": "uint256", - "name": "difficulty", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getCurrentBlockGasLimit", - "outputs": [ - { - "internalType": "uint256", - "name": "gaslimit", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getCurrentBlockTimestamp", - "outputs": [ - { - "internalType": "uint256", - "name": "timestamp", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "addr", - "type": "address" - } - ], - "name": "getEthBalance", - "outputs": [ - { - "internalType": "uint256", - "name": "balance", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "getLastBlockHash", - "outputs": [ - { - "internalType": "bytes32", - "name": "blockHash", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bool", - "name": "requireSuccess", - "type": "bool" - }, - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "tryAggregate", - "outputs": [ - { - "components": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "returnData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Result[]", - "name": "returnData", - "type": "tuple[]" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bool", - "name": "requireSuccess", - "type": "bool" - }, - { - "components": [ - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bytes", - "name": "callData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Call[]", - "name": "calls", - "type": "tuple[]" - } - ], - "name": "tryBlockAndAggregate", - "outputs": [ - { - "internalType": "uint256", - "name": "blockNumber", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "blockHash", - "type": "bytes32" - }, - { - "components": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - }, - { - "internalType": "bytes", - "name": "returnData", - "type": "bytes" - } - ], - "internalType": "struct Multicall3.Result[]", - "name": "returnData", - "type": "tuple[]" - } - ], - "stateMutability": "payable", - "type": "function" - } -] as const; diff --git a/market-maker/src/abi/PortfolioMarginEngine.ts b/market-maker/src/abi/PortfolioMarginEngine.ts deleted file mode 100644 index 2ae082c..0000000 --- a/market-maker/src/abi/PortfolioMarginEngine.ts +++ /dev/null @@ -1,588 +0,0 @@ -export const PortfolioMarginEngineAbi = [ - { - "inputs": [], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "target", - "type": "address" - } - ], - "name": "AddressEmptyCode", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "ERC1967InvalidImplementation", - "type": "error" - }, - { - "inputs": [], - "name": "ERC1967NonPayable", - "type": "error" - }, - { - "inputs": [], - "name": "FailedCall", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidInitialization", - "type": "error" - }, - { - "inputs": [], - "name": "NotInitializing", - "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": "UUPSUnauthorizedCallContext", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "slot", - "type": "bytes32" - } - ], - "name": "UUPSUnsupportedProxiableUUID", - "type": "error" - }, - { - "inputs": [], - "name": "ZeroAddress", - "type": "error" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "futures", - "type": "address" - } - ], - "name": "FuturesUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" - } - ], - "name": "Initialized", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "optionsEngine", - "type": "address" - } - ], - "name": "OptionsEngineUpdated", - "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": "perpsDex", - "type": "address" - } - ], - "name": "PerpsDexUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "imSpot", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "mmSpot", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "imVol", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "mmVol", - "type": "uint256" - } - ], - "name": "ShocksUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "Upgraded", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "vault", - "type": "address" - } - ], - "name": "VaultUpdated", - "type": "event" - }, - { - "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": "address", - "name": "user", - "type": "address" - }, - { - "internalType": "uint256", - "name": "additionalIM", - "type": "uint256" - } - ], - "name": "canPlaceOrder", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "user", - "type": "address" - } - ], - "name": "computePortfolioIM", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "user", - "type": "address" - } - ], - "name": "computePortfolioMM", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "futures", - "outputs": [ - { - "internalType": "contract IFutures", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "imSpotShock", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "imVolShock", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_vault", - "type": "address" - } - ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "user", - "type": "address" - } - ], - "name": "isHealthy", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "mmSpotShock", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "mmVolShock", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "optionsEngine", - "outputs": [ - { - "internalType": "contract IOptionsEnginePortfolioView", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "owner", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "perpsDex", - "outputs": [ - { - "internalType": "contract IHashPowerPerpsDEX", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "proxiableUUID", - "outputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_futuresEngine", - "type": "address" - } - ], - "name": "setFutures", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_optionsEngine", - "type": "address" - } - ], - "name": "setOptions", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_perpsEngine", - "type": "address" - } - ], - "name": "setPerps", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_imSpotShock", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_mmSpotShock", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_imVolShock", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_mmVolShock", - "type": "uint256" - } - ], - "name": "setShocks", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_vault", - "type": "address" - } - ], - "name": "setVault", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "transferOwnership", - "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" - } -] as const; diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 63b4a96..82e07a1 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -1,6 +1,6 @@ import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; -import { FuturesAbi } from "../../abi/Futures.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; export const FUTURES_INSTRUMENT_ID = "futures"; diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index b8699b0..46179d5 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -11,8 +11,7 @@ import type { OrderIntent, Position, } from "../../core/adapter.ts"; -import { FuturesAbi } from "../../abi/Futures.ts"; -import { FuturesMmExtensionsAbi } from "../../abi/FuturesMmExtensions.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; @@ -105,7 +104,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { encodeCancel(intent: CancelIntent): `0x${string}` { return encodeFunctionData({ - abi: FuturesMmExtensionsAbi, + abi: FuturesAbi, functionName: "closeOrder", args: [intent.orderId], }); @@ -190,8 +189,8 @@ class FuturesBook implements BookSource { const [bidPrices, askPrices] = await v.publicClient.multicall({ allowFailure: false, contracts: [ - { address: v.address, abi: FuturesMmExtensionsAbi, functionName: "getBidPrices", args: [dd, depth] }, - { address: v.address, abi: FuturesMmExtensionsAbi, functionName: "getAskPrices", args: [dd, depth] }, + { address: v.address, abi: FuturesAbi, functionName: "getBidPrices", args: [dd, depth] }, + { address: v.address, abi: FuturesAbi, functionName: "getAskPrices", args: [dd, depth] }, ], }); @@ -200,13 +199,13 @@ class FuturesBook implements BookSource { const calls = [ ...bidPrices.map((p) => ({ address: v.address, - abi: FuturesMmExtensionsAbi, + abi: FuturesAbi, functionName: "getQuantityAtPrice" as const, args: [dd, p, true] as const, })), ...askPrices.map((p) => ({ address: v.address, - abi: FuturesMmExtensionsAbi, + abi: FuturesAbi, functionName: "getQuantityAtPrice" as const, args: [dd, p, false] as const, })), diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 8fee1b1..81e6e18 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -5,8 +5,7 @@ import type { OwnOrderSource, Unsubscribe, } from "../../core/adapter.ts"; -import { FuturesAbi } from "../../abi/Futures.ts"; -import { FuturesMmExtensionsAbi } from "../../abi/FuturesMmExtensions.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FUTURES_INSTRUMENT_ID } from "./events.ts"; @@ -62,7 +61,7 @@ export class FuturesOwnOrders implements OwnOrderSource { const orderIds = await this.venue.publicClient.readContract({ address: this.venue.address, - abi: FuturesMmExtensionsAbi, + abi: FuturesAbi, functionName: "getOrderIds", args: [owner], }); diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 54b642e..6e3113e 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -10,10 +10,10 @@ import type { WalletContext, } from "../../core/adapter.ts"; import type { NetworkClients } from "../../core/client.ts"; -import { FuturesAbi } from "../../abi/Futures.ts"; -import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; -import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; -import { Multicall3Abi } from "../../abi/Multicall3.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures.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"; import { depositToVault } from "../../core/vaultDeposit.ts"; import { RawOracleReader } from "../../core/rawOracle.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; diff --git a/market-maker/src/adapters/perps/events.ts b/market-maker/src/adapters/perps/events.ts index 11e8d1b..032f7b2 100644 --- a/market-maker/src/adapters/perps/events.ts +++ b/market-maker/src/adapters/perps/events.ts @@ -1,6 +1,6 @@ import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; -import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; +import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.ts"; const PERPS_INSTRUMENT_ID = "perps"; diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index b9c7bf9..532c51b 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -14,7 +14,7 @@ import type { Position, Unsubscribe, } from "../../core/adapter.ts"; -import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; +import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.ts"; import { calculateNotional } from "../../core/math.ts"; import type { PerpsVenueAdapter } from "./venue.ts"; diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 9da37b6..8f1cb3c 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -10,10 +10,10 @@ import type { WalletContext, } from "../../core/adapter.ts"; import type { NetworkClients } from "../../core/client.ts"; -import { HashPowerPerpsDEXAbi } from "../../abi/HashPowerPerpsDEX.ts"; -import { CollateralVaultAbi } from "../../abi/CollateralVault.ts"; -import { PortfolioMarginEngineAbi } from "../../abi/PortfolioMarginEngine.ts"; -import { Multicall3Abi } from "../../abi/Multicall3.ts"; +import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.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"; import { depositToVault } from "../../core/vaultDeposit.ts"; import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; import { PerpsInstrumentAdapter } from "./instrument.ts"; From 66199fa0453166b9188ecebe8ad515be053bc59e Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 5 May 2026 14:23:07 -0500 Subject: [PATCH 008/180] feat(infra): add bedrock terraform + deploy workflow for col-mar market maker Provisions perps and futures market-maker ECS services per env (dev/stg/lmn) sharing the derivatives marketplace ECS cluster via Terraform data lookup. Each venue gets its own ALB, target group, Route53 record (perpsmm.{env}.hashpower.exchange and futuresmm.{env}.hashpower.exchange), CloudWatch log group, monitoring stack, and OIDC GitHub Actions IAM scope. Terraform is scaffolding-only: task definitions ship a stub busybox container with desired_count=0 and lifecycle.ignore_changes on task_definition + container_definitions. The new deploy-col-mar-mm.yml workflow builds the image from market-maker/, pushes to GHCR, and registers a new task-def revision per venue with image + env vars from GitHub Variables and secrets from GitHub Secrets. No AWS Secrets Manager dependency. Secrets flow into the deploy step's env block via inline ternaries (matrix.app == 'perps' && SECRET_A || SECRET_B); they are never written to step outputs or to disk on the runner. DEV environment activated for both venues. STG and LMN scaffolding is in place but services have create=false pending operator readiness. Co-authored-by: Cursor --- .bedrock/.terragrunt/00_bedrock.tf | 111 +++ .bedrock/.terragrunt/00_data_global.tf | 103 +++ .bedrock/.terragrunt/00_data_use1_1.tf | 88 ++ .bedrock/.terragrunt/00_outputs.tf | 27 + .bedrock/.terragrunt/00_variables.tf | 94 +++ .bedrock/.terragrunt/00_variables_local.tf | 46 ++ .bedrock/.terragrunt/01_github_actions_iam.tf | 184 +++++ .bedrock/.terragrunt/04_futures_mm_svc.tf | 317 ++++++++ .bedrock/.terragrunt/04_perps_mm_svc.tf | 339 ++++++++ .bedrock/.terragrunt/05_futures_mm_mon.tf | 744 +++++++++++++++++ .bedrock/.terragrunt/05_perps_mm_mon.tf | 759 ++++++++++++++++++ .bedrock/02-dev/dnsprovider.tf | 12 + .bedrock/02-dev/terraform.tfvars | 68 ++ .bedrock/02-dev/terragrunt.hcl | 3 + .bedrock/03-stg/dnsprovider.tf | 12 + .bedrock/03-stg/terraform.tfvars | 66 ++ .bedrock/03-stg/terragrunt.hcl | 3 + .bedrock/04-lmn/dnsprovider.tf | 12 + .bedrock/04-lmn/terraform.tfvars | 66 ++ .bedrock/04-lmn/terragrunt.hcl | 3 + .bedrock/root.hcl | 21 + .cursorignore | 10 +- .github/workflows/deploy-col-mar-mm.yml | 470 +++++++++++ .gitignore | 11 +- 24 files changed, 3567 insertions(+), 2 deletions(-) create mode 100644 .bedrock/.terragrunt/00_bedrock.tf create mode 100644 .bedrock/.terragrunt/00_data_global.tf create mode 100644 .bedrock/.terragrunt/00_data_use1_1.tf create mode 100644 .bedrock/.terragrunt/00_outputs.tf create mode 100644 .bedrock/.terragrunt/00_variables.tf create mode 100644 .bedrock/.terragrunt/00_variables_local.tf create mode 100644 .bedrock/.terragrunt/01_github_actions_iam.tf create mode 100644 .bedrock/.terragrunt/04_futures_mm_svc.tf create mode 100644 .bedrock/.terragrunt/04_perps_mm_svc.tf create mode 100644 .bedrock/.terragrunt/05_futures_mm_mon.tf create mode 100644 .bedrock/.terragrunt/05_perps_mm_mon.tf create mode 100644 .bedrock/02-dev/dnsprovider.tf create mode 100644 .bedrock/02-dev/terraform.tfvars create mode 100644 .bedrock/02-dev/terragrunt.hcl create mode 100644 .bedrock/03-stg/dnsprovider.tf create mode 100644 .bedrock/03-stg/terraform.tfvars create mode 100644 .bedrock/03-stg/terragrunt.hcl create mode 100644 .bedrock/04-lmn/dnsprovider.tf create mode 100644 .bedrock/04-lmn/terraform.tfvars create mode 100644 .bedrock/04-lmn/terragrunt.hcl create mode 100644 .bedrock/root.hcl create mode 100644 .github/workflows/deploy-col-mar-mm.yml diff --git a/.bedrock/.terragrunt/00_bedrock.tf b/.bedrock/.terragrunt/00_bedrock.tf new file mode 100644 index 0000000..1617a8d --- /dev/null +++ b/.bedrock/.terragrunt/00_bedrock.tf @@ -0,0 +1,111 @@ +################################################################################ +# VERSIONS +################################################################################ +terraform { + required_version = ">= 1.2.7" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.37.0" # Latest version as of 2026-01-15 + } + } +} + +################################################################################ +# Profiles - Interpolation is not supported for the 'version' input +################################################################################ +# Default profile - used for global configs and where a provider is not defined +provider "aws" { + region = "us-east-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] # all resources will ignore any addition of tags with the kubernetes.io/ prefix + } +} + +########################## +# Region-specific profiles +########################## +# titanio-net +provider "aws" { + alias = "titanio-net" + region = "us-east-1" + profile = "titanio-net" + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# titanio-prd +provider "aws" { + alias = "titanio-prd" + region = "us-east-1" + profile = "titanio-prd" + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} + +# Virginia +provider "aws" { + alias = "use1" + region = "us-east-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# Ohio +provider "aws" { + alias = "use2" + region = "us-east-2" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# California +provider "aws" { + alias = "usw1" + region = "us-west-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# Oregon +provider "aws" { + alias = "usw2" + region = "us-west-2" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} + +# Frankfurt +provider "aws" { + alias = "euc1" + region = "eu-central-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# Singapore +provider "aws" { + alias = "apse1" + region = "ap-southeast-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} +# Hong Kong +provider "aws" { + alias = "ape1" + region = "ap-east-1" + profile = var.provider_profile + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} diff --git a/.bedrock/.terragrunt/00_data_global.tf b/.bedrock/.terragrunt/00_data_global.tf new file mode 100644 index 0000000..7cfdeba --- /dev/null +++ b/.bedrock/.terragrunt/00_data_global.tf @@ -0,0 +1,103 @@ +################################################################################ +# APP-SPECIFIC GLOBAL LOOKUPS (data files, dns, iam, etc...) +################################################################################ + +################################################################################ +# DEVOPS/BEDROCK SOURCE INFO +################################################################################ + +################################ +# WAF Protection - for Cloudfront (Global Scope) +################################ +data "aws_wafv2_web_acl" "bedrock_waf_cloudfront" { + provider = aws.use1 + name = "waf-bedrock-cloudfront" + scope = "CLOUDFRONT" +} +################################################################################ +# ECS CLUSTER LOOKUP +################################################################################ +# The ECS cluster is owned by the derivatives-marketplace repo (resource +# aws_ecs_cluster.derivatives_marketplace). We share the cluster across +# both repos to avoid running two Fargate clusters per account. +# +# Coupling note: if derivatives-marketplace destroys the cluster, the +# services defined in this repo will stop. Coordinate cluster-level +# changes between the two repos. +################################################################################ + +data "aws_ecs_cluster" "derivatives" { + provider = aws.use1 + cluster_name = local.derivatives_ecs_cluster_name +} + + +################################ +# Hashpower DNS & ACM Lookups +# +# Conditional: lmn resolves root domains, dev/stg resolve env subdomains. +# Dependent code uses the same local reference regardless of account. +# +# Usage: +# DNS zone: +# local.hp_dns["exc"].zone_id +# local.hp_dns["exc"].name # "hashpower.exchange" (lmn) or "dev.hashpower.exchange" (dev) +# local.hp_dns["tok"].zone_id +# +# ACM cert: +# local.hp_acm["exc"].arn +# local.hp_acm["tok"].arn +# +# Keys: exc = hashpower.exchange, tok = hpow.io, com = hashpower.io (when acquired) +################################ +locals { + env_prefix = substr(var.account_shortname, 8, 3) + is_lmn = local.env_prefix == "lmn" + + hashpower_domains = { + exc = "hashpower.exchange" + tok = "hpow.io" + # com = "hashpower.io" # uncomment when domain is acquired + } +} + +# DNS: root zones in titanio-net (lmn only) +data "aws_route53_zone" "hp_root" { + for_each = local.is_lmn ? local.hashpower_domains : {} + provider = aws.titanio-net + name = each.value + private_zone = false +} + +# DNS: env subdomain zones in local account (dev, stg) +data "aws_route53_zone" "hp_env" { + for_each = local.is_lmn ? {} : local.hashpower_domains + provider = aws.use1 + name = "${local.env_prefix}.${each.value}" + private_zone = false +} + +# ACM: always in local account, domain conditional on env +data "aws_acm_certificate" "hp" { + for_each = local.hashpower_domains + provider = aws.use1 + domain = local.is_lmn ? each.value : "${local.env_prefix}.${each.value}" + statuses = ["ISSUED"] +} + +locals { + hp_dns = local.is_lmn ? data.aws_route53_zone.hp_root : data.aws_route53_zone.hp_env + hp_acm = data.aws_acm_certificate.hp +} + +output "hp_dns" { + value = { for k, v in local.hp_dns : k => { zone_id = v.zone_id, name = v.name } } +} + +output "hp_acm" { + value = { for k, v in local.hp_acm : k => { + arn = v.arn + domain = local.is_lmn ? local.hashpower_domains[k] : "${local.env_prefix}.${local.hashpower_domains[k]}" + } } +} + diff --git a/.bedrock/.terragrunt/00_data_use1_1.tf b/.bedrock/.terragrunt/00_data_use1_1.tf new file mode 100644 index 0000000..6363108 --- /dev/null +++ b/.bedrock/.terragrunt/00_data_use1_1.tf @@ -0,0 +1,88 @@ +################################ +# Regional DATA LOOKUPS +################################ + +data "aws_vpc" "use1_1" { + provider = aws.use1 + tags = { + Name = "vpc-${var.region_shortname}-${var.vpc_index}-${var.account_shortname}" + } +} +data "aws_internet_gateway" "use1_1" { + provider = aws.use1 + filter { + name = "attachment.vpc-id" + values = [data.aws_vpc.use1_1.id] + } +} + +data "aws_subnet" "edge_use1_1" { + provider = aws.use1 + count = 3 + filter { + name = "tag:Name" + values = ["sn-use1-1-${var.account_shortname}-edge-${count.index + 1}"] + } + # in code for sgs, use the following: subnet_ids = [for n in data.aws_subnet.edge_use1_1 : n.id] +} + +data "aws_subnet" "middle_use1_1" { + provider = aws.use1 + count = 3 + filter { + name = "tag:Name" + values = ["sn-use1-1-${var.account_shortname}-middle-${count.index + 1}"] + } + # in code for sgs, use the following: subnet_ids = [for n in data.aws_subnet.middle_use1_1 : n.id] +} + +data "aws_subnet" "private_use1_1" { + provider = aws.use1 + count = 3 + filter { + name = "tag:Name" + values = ["sn-use1-1-${var.account_shortname}-private-${count.index + 1}"] + } + # in code for sgs, use the following: subnet_ids = [for n in data.aws_subnet.private_use1_1 : n.id] +} + +data "aws_subnet" "edge_use1_1a" { + provider = aws.use1 + filter { + name = "tag:Name" + values = ["sn-use1-1-${var.account_shortname}-edge-1"] + } +} + +data "aws_subnet" "middle_use1_1a" { + provider = aws.use1 + filter { + name = "tag:Name" + values = ["sn-use1-1-${var.account_shortname}-middle-1"] + } +} + +# Regional ALB: ACM for the env public zone (hashpower.exchange or dev/stg.hashpower.exchange) +data "aws_acm_certificate" "lumerin_marketplace_ext" { + provider = aws.use1 + domain = local.hp_dns["exc"].name + types = ["AMAZON_ISSUED"] + most_recent = true +} + +# CloudFront / website (if used): same zone as hp_acm["exc"] +data "aws_acm_certificate" "lumerin_marketplace_website" { + provider = aws.use1 + domain = local.hp_dns["exc"].name + types = ["AMAZON_ISSUED"] + most_recent = true +} + +################################ +# WAF Protection +################################ +data "aws_wafv2_web_acl" "bedrock_waf_use1_1" { + provider = aws.use1 + name = "waf-bedrock-use1-1" + scope = "REGIONAL" +} \ No newline at end of file diff --git a/.bedrock/.terragrunt/00_outputs.tf b/.bedrock/.terragrunt/00_outputs.tf new file mode 100644 index 0000000..b68f8b4 --- /dev/null +++ b/.bedrock/.terragrunt/00_outputs.tf @@ -0,0 +1,27 @@ +################################################################################ +# OUTPUTS - # Usage: terragrunt output +################################################################################ + +output "github_actions_role_arn" { + description = "ARN of the IAM role for GitHub Actions" + value = var.create_core ? aws_iam_role.github_actions_collateral_margin[0].arn : null +} + +output "github_actions_role_name" { + description = "Name of the IAM role for GitHub Actions" + value = var.create_core ? aws_iam_role.github_actions_collateral_margin[0].name : null +} + +################################################################################ +# SERVICE ENDPOINTS (internal ALB, reachable via VPN) +################################################################################ + +output "perps_mm_endpoint" { + description = "Perps Market Maker health endpoint (internal ALB via VPN)" + value = var.perps_mm_service.create ? "https://perpsmm.${local.hp_dns["exc"].name}/health" : null +} + +output "futures_mm_endpoint" { + description = "Futures Market Maker health endpoint (internal ALB via VPN)" + value = var.futures_mm_service.create ? "https://futuresmm.${local.hp_dns["exc"].name}/health" : null +} diff --git a/.bedrock/.terragrunt/00_variables.tf b/.bedrock/.terragrunt/00_variables.tf new file mode 100644 index 0000000..8534693 --- /dev/null +++ b/.bedrock/.terragrunt/00_variables.tf @@ -0,0 +1,94 @@ +variable "create_core" { + description = "Decide whether or not to create the core resources (GitHub Actions IAM role)" + type = bool + default = false +} + +################################################################################ +# MARKET MAKER SERVICES (per venue) - SCAFFOLDING ONLY +################################################################################ +# Two independent ECS services running the same Docker image but different +# entry points (MAKER_APP=perps vs MAKER_APP=futures). Terraform owns ONLY +# the scaffolding (security groups, ALB, target group, listener, Route53, +# log group, service shell, initial task-def stub). +# +# Personality (image, env vars, secrets) is owned by the deploy-col-mar-mm.yml +# workflow, which builds the image, pushes to GHCR, and registers a new +# task-def revision per deploy. Both ECS service.task_definition and ECS +# task_definition.container_definitions are in lifecycle.ignore_changes; +# Terraform never updates them after first apply. +# +# Service map fields (all scaffolding): +# create bool - toggle the entire service stack +# task_worker_qty number - desired_count INITIAL value; CI/CD owns it after first deploy +# cnt_port number - container port + target-group port + SG ingress rule +# task_cpu number - Fargate CPU units for the task +# task_ram number - Fargate memory (MB) for the task +################################################################################ + +variable "perps_mm_service" { + description = "Perps Market Maker ECS service scaffolding" + type = object({ + create = bool + task_worker_qty = number + cnt_port = number + task_cpu = number + task_ram = number + }) + default = { + create = false + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 + } +} + +variable "futures_mm_service" { + description = "Futures Market Maker ECS service scaffolding" + type = object({ + create = bool + task_worker_qty = number + cnt_port = number + task_cpu = number + task_ram = number + }) + default = { + create = false + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 + } +} + +################################################################################ +# Common Account Variables +################################################################################ +variable "account_shortname" { description = "Code describing customer and lifecycle. E.g., titanio-dev, titanio-stg, titanio-lmn" } +variable "account_lifecycle" { + description = "environment lifecycle: 'dev', 'stg', 'prd' (lmn uses 'prd')" + type = string +} +variable "account_number" {} +variable "default_region" {} +variable "region_shortname" { + description = "Region 4 character shortname" + default = "use1" +} +variable "vpc_index" {} +variable "devops_keypair" {} +variable "titanio_net_edge_vpn" {} +variable "protect_environment" {} +variable "ecs_task_role_arn" {} +variable "default_tags" { + description = "Default tag values common across all resources in this account." + type = map(string) +} +variable "foundation_tags" { + description = "Default Tags for Bedrock Foundation resources" + type = map(string) +} +variable "provider_profile" { + description = "AWS profile name used by the default provider" +} diff --git a/.bedrock/.terragrunt/00_variables_local.tf b/.bedrock/.terragrunt/00_variables_local.tf new file mode 100644 index 0000000..f5465f1 --- /dev/null +++ b/.bedrock/.terragrunt/00_variables_local.tf @@ -0,0 +1,46 @@ +################################ +# LOCAL VARIABLES +################################ +locals { + # Short product code used in resource names. Bounded by AWS limits + # (ALB <= 32 chars, Target Group <= 32 chars). Combined with venue + # suffix (-perps-mm / -futures-mm) and env (-dev|stg|lmn), names like + # alb-col-mar-futures-mm-dev land at 26 chars. + shortname = "col-mar" + log_group_name = "bedrock-${local.shortname}-${substr(var.account_shortname, 8, 3)}" + cloudwatch_event_retention = 90 + + titanio_net_ecr = "343351459450.dkr.ecr.us-east-1.amazonaws.com" + titanio_role_arn = "arn:aws:iam::${var.account_number}:role/system/bedrock-foundation-role" + + # Cluster lookup target. The derivatives-marketplace repo provisions + # this cluster (resource aws_ecs_cluster.derivatives_marketplace). + # If that resource ever moves or renames, update this single line. + derivatives_ecs_cluster_name = "ecs-derivatives-marketplace-${substr(var.account_shortname, 8, 3)}" + + # MAKER_ENV value injected into containers; the docker entrypoint + # uses it to select configs/{perps,futures}.${MAKER_ENV}.yml. Production + # accounts (lmn) use the "prd" YAML; dev/stg map 1:1. + maker_env = var.account_lifecycle == "prd" ? "prd" : var.account_lifecycle + + ################################ + # GITHUB ACTIONS CI/CD + ################################ + # NOTE: Case-sensitive! Must match GitHub exactly. + github_org_repo = "Lumerin-protocol/collateral-margin" + + # DEV uses a list to allow both dev and cicd/* branches; STG/PRD use single-item lists. + github_branch_filter = var.account_lifecycle == "dev" ? [ + "ref:refs/heads/dev", + "ref:refs/heads/cicd/*", + "environment:dev" + ] : ( + var.account_lifecycle == "stg" ? ["ref:refs/heads/stg", "environment:stg"] : ["ref:refs/heads/main", "environment:main"] + ) + + ################################ + # DOMAIN CONSTRUCTION (from Route53 data lookups) + ################################ + # Public zone for this env: hashpower.exchange (lmn) or {dev,stg}.hashpower.exchange. + domain_zone_name = local.hp_dns["exc"].name +} diff --git a/.bedrock/.terragrunt/01_github_actions_iam.tf b/.bedrock/.terragrunt/01_github_actions_iam.tf new file mode 100644 index 0000000..59dbd9b --- /dev/null +++ b/.bedrock/.terragrunt/01_github_actions_iam.tf @@ -0,0 +1,184 @@ +################################################################################ +# GITHUB ACTIONS IAM ROLE AND POLICIES +################################################################################ +# Bare-minimum IAM for the deploy-col-mar-mm.yml workflow: +# - register new ECS task definitions +# - update both perps + futures services to point at the new revisions +# - PassRole the existing bedrock-foundation-role into ECS tasks +# +# All runtime config (env vars, secrets, contract addresses, RPC keys) is +# managed in GitHub Variables / Secrets and baked into each task-def +# revision by the workflow. There are no AWS Secrets Manager resources to +# read here. +# +# OIDC provider bootstrap (run once per account if not already present): +# aws iam create-open-id-connect-provider \ +# --url https://token.actions.githubusercontent.com \ +# --client-id-list sts.amazonaws.com \ +# --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 1b511abead59c6ce207077c0bf0e0043b1382612 \ +# --profile titanio- +################################################################################ + +data "aws_iam_openid_connect_provider" "github" { + provider = aws.use1 + url = "https://token.actions.githubusercontent.com" +} + +################################################################################ +# IAM ROLE FOR GITHUB ACTIONS +################################################################################ + +resource "aws_iam_role" "github_actions_collateral_margin" { + count = var.create_core ? 1 : 0 + provider = aws.use1 + name = "github-actions-${local.shortname}-v1-${substr(var.account_shortname, 8, 3)}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = data.aws_iam_openid_connect_provider.github.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + StringLike = { + "token.actions.githubusercontent.com:sub" = [ + for branch_filter in local.github_branch_filter : + "repo:${local.github_org_repo}:${branch_filter}" + ] + } + } + } + ] + }) + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "GitHub Actions - Collateral Margin" + Capability = "CI/CD" + }) +} + +################################################################################ +# ECS UPDATE POLICY - Perps Market Maker service +################################################################################ + +resource "aws_iam_role_policy" "github_ecs_update_perps_mm" { + count = var.create_core && var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "ecs-update-${local.shortname}-perps-mm" + role = aws_iam_role.github_actions_collateral_margin[count.index].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "UpdatePerpsMmECSService" + Effect = "Allow" + Action = [ + "ecs:UpdateService", + "ecs:DescribeServices" + ] + Resource = [ + aws_ecs_service.perps_mm_use1[count.index].id + ] + }, + { + Sid = "TaskDefinitionOperations" + Effect = "Allow" + Action = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition" + ] + Resource = "*" + }, + { + Sid = "PassRoleToECS" + Effect = "Allow" + Action = "iam:PassRole" + Resource = [ + var.ecs_task_role_arn, + local.titanio_role_arn + ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } + }, + { + Sid = "ReadECSCluster" + Effect = "Allow" + Action = [ + "ecs:ListServices", + "ecs:DescribeClusters" + ] + Resource = "*" + } + ] + }) +} + +################################################################################ +# ECS UPDATE POLICY - Futures Market Maker service +################################################################################ + +resource "aws_iam_role_policy" "github_ecs_update_futures_mm" { + count = var.create_core && var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "ecs-update-${local.shortname}-futures-mm" + role = aws_iam_role.github_actions_collateral_margin[count.index].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "UpdateFuturesMmECSService" + Effect = "Allow" + Action = [ + "ecs:UpdateService", + "ecs:DescribeServices" + ] + Resource = [ + aws_ecs_service.futures_mm_use1[count.index].id + ] + }, + { + Sid = "TaskDefinitionOperations" + Effect = "Allow" + Action = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition" + ] + Resource = "*" + }, + { + Sid = "PassRoleToECS" + Effect = "Allow" + Action = "iam:PassRole" + Resource = [ + var.ecs_task_role_arn, + local.titanio_role_arn + ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } + }, + { + Sid = "ReadECSCluster" + Effect = "Allow" + Action = [ + "ecs:ListServices", + "ecs:DescribeClusters" + ] + Resource = "*" + } + ] + }) +} diff --git a/.bedrock/.terragrunt/04_futures_mm_svc.tf b/.bedrock/.terragrunt/04_futures_mm_svc.tf new file mode 100644 index 0000000..47adb2a --- /dev/null +++ b/.bedrock/.terragrunt/04_futures_mm_svc.tf @@ -0,0 +1,317 @@ +################################################################################ +# FUTURES MARKET MAKER - ECS SERVICE (SCAFFOLDING) +################################################################################ +# Mirror of 04_perps_mm_svc.tf for MAKER_APP=futures. Same CI/CD-owned +# personality model: Terraform builds infra, deploy-col-mar-mm.yml owns +# image / env vars / secrets / desired_count after first apply. +# +# Replaces the legacy futures market-maker Lambda (futures-marketplace, +# 10_market_maker_lambda.tf). DNS name `futuresmm.{env}.hashpower.exchange` +# does not collide with anything currently in derivatives or futures repos, +# so this can be applied immediately. +################################################################################ + +locals { + futures_mm_env_suffix = substr(var.account_shortname, 8, 3) +} + +################################ +# SECURITY GROUPS +################################ + +resource "aws_security_group" "futures_mm_alb_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-alb-${local.futures_mm_env_suffix}" + description = "Security group for Futures Market Maker internal ALB" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTPS from VPC and VPN" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.use1_1.cidr_block, "172.18.0.0/19"] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM ALB Security Group", + Capability = null, + }, + ) +} + +resource "aws_security_group" "futures_mm_ecs_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-ecs-${local.futures_mm_env_suffix}" + description = "Security group for Futures Market Maker ECS tasks" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTP from ALB" + from_port = var.futures_mm_service.cnt_port + to_port = var.futures_mm_service.cnt_port + protocol = "tcp" + security_groups = [aws_security_group.futures_mm_alb_use1[count.index].id] + } + + egress { + description = "Allow all outbound (RPC + chain access)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM ECS Security Group", + Capability = null, + }, + ) +} + +################################ +# CLOUDWATCH LOGS +################################ + +resource "aws_cloudwatch_log_group" "futures_mm_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "/ecs/${local.shortname}-futures-mm-${local.futures_mm_env_suffix}" + retention_in_days = 7 + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM ECS Log Group", + Capability = null, + }, + ) +} + +################################ +# APPLICATION LOAD BALANCER (INTERNAL) +################################ + +resource "aws_alb" "futures_mm_int_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "alb-${local.shortname}-futures-mm-${local.futures_mm_env_suffix}" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.futures_mm_alb_use1[count.index].id] + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + enable_deletion_protection = false + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM Internal ALB", + Capability = null, + }, + ) +} + +resource "aws_alb_target_group" "futures_mm_int_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "tg-${local.shortname}-futures-mm-${local.futures_mm_env_suffix}" + port = tonumber(var.futures_mm_service.cnt_port) + protocol = "HTTP" + vpc_id = data.aws_vpc.use1_1.id + target_type = "ip" + load_balancing_algorithm_type = "round_robin" + deregistration_delay = "10" + + health_check { + enabled = true + interval = 30 + path = "/health" + port = var.futures_mm_service.cnt_port + protocol = "HTTP" + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM Target Group", + Capability = null, + }, + ) +} + +resource "aws_alb_listener" "futures_mm_int_443_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + load_balancer_arn = aws_alb.futures_mm_int_use1[count.index].arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-FS-1-2-Res-2020-10" + certificate_arn = local.hp_acm["exc"].arn + + default_action { + type = "forward" + target_group_arn = aws_alb_target_group.futures_mm_int_use1[count.index].arn + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM HTTPS Listener", + Capability = null, + }, + ) +} + +resource "aws_route53_record" "futures_mm_int_use1" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + zone_id = local.hp_dns["exc"].zone_id + name = "futuresmm.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.futures_mm_int_use1[count.index].dns_name + zone_id = aws_alb.futures_mm_int_use1[count.index].zone_id + evaluate_target_health = true + } +} + +################################ +# ECS SERVICE & TASK +################################ + +resource "aws_ecs_service" "futures_mm_use1" { + lifecycle { ignore_changes = [task_definition, desired_count] } + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "svc-${local.shortname}-futures-mm-${local.futures_mm_env_suffix}" + cluster = data.aws_ecs_cluster.derivatives.arn + task_definition = aws_ecs_task_definition.futures_mm_use1[count.index].arn + desired_count = 0 + launch_type = "FARGATE" + propagate_tags = "SERVICE" + enable_execute_command = true + + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + assign_public_ip = false + security_groups = [aws_security_group.futures_mm_ecs_use1[count.index].id] + } + + load_balancer { + target_group_arn = aws_alb_target_group.futures_mm_int_use1[count.index].arn + container_name = "${local.shortname}-futures-mm-container" + container_port = var.futures_mm_service.cnt_port + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM Service", + Capability = null, + }, + ) +} + +resource "aws_ecs_task_definition" "futures_mm_use1" { + lifecycle { ignore_changes = [container_definitions] } + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + family = "tsk-${local.shortname}-futures-mm" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.futures_mm_service.task_cpu + memory = var.futures_mm_service.task_ram + task_role_arn = local.titanio_role_arn + execution_role_arn = local.titanio_role_arn + + # STUB CONTAINER. CI/CD overwrites this on every deploy; the only thing + # that matters here is that the task def is registerable. See the perps + # equivalent for full rationale. + container_definitions = jsonencode([ + { + name = "${local.shortname}-futures-mm-container" + image = "public.ecr.aws/docker/library/busybox:latest" + command = ["sh", "-c", "echo 'col-mar futures-mm stub - awaiting CI/CD deploy'; sleep infinity"] + cpu = 0 + essential = true + + portMappings = [ + { + containerPort = tonumber(var.futures_mm_service.cnt_port) + hostPort = tonumber(var.futures_mm_service.cnt_port) + protocol = "tcp" + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-create-group" = "true" + "awslogs-group" = aws_cloudwatch_log_group.futures_mm_use1[0].name + "awslogs-region" = var.default_region + "awslogs-stream-prefix" = "${local.shortname}-futures-mm-tsk" + } + } + } + ]) + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Futures MM ECS Task Definition", + Capability = null, + }, + ) +} + +################################ +# ACCESS INFORMATION +################################ +# Endpoint: +# DEV: https://futuresmm.dev.hashpower.exchange/health +# STG: https://futuresmm.stg.hashpower.exchange/health +# LMN: https://futuresmm.hashpower.exchange/health +# +# Access restricted by ALB security group to: +# - VPC CIDR: data.aws_vpc.use1_1.cidr_block +# - VPN CIDR: 172.18.0.0/19 +# +# Architecture: +# futuresmm.{env}.hashpower.exchange (Route53 A record) +# -> Internal ALB (HTTPS:443) +# -> Target Group (health check: /health) +# -> ECS Task (HTTP:cnt_port) diff --git a/.bedrock/.terragrunt/04_perps_mm_svc.tf b/.bedrock/.terragrunt/04_perps_mm_svc.tf new file mode 100644 index 0000000..65f4158 --- /dev/null +++ b/.bedrock/.terragrunt/04_perps_mm_svc.tf @@ -0,0 +1,339 @@ +################################################################################ +# PERPS MARKET MAKER - ECS SERVICE (SCAFFOLDING) +################################################################################ +# Terraform builds the immutable infrastructure: SGs, internal ALB, +# target group, HTTPS listener, Route53 record, CloudWatch log group, +# ECS service (with desired_count=0), and an initial stub task definition. +# +# The deploy-col-mar-mm.yml workflow is responsible for everything that +# changes per release: +# - building the Docker image and pushing to GHCR +# - rendering env vars from GitHub Variables / Secrets +# - registering new task-def revisions +# - calling ecs:UpdateService to point the service at the new revision +# and to scale desired_count up to the value chosen by the operator +# +# Both task_definition (on the service) and container_definitions / desired_count +# (on the task def + service) are in lifecycle.ignore_changes so Terraform +# never reverts what CI/CD has done. +# +# DNS COLLISION NOTE: derivatives-marketplace currently owns the same DNS +# name (perpsmm.{env}.hashpower.exchange) for its legacy perps MM. Until +# the legacy stack is destroyed, leave var.perps_mm_service.create=false +# in this repo to avoid a Route53 conflict on apply. +################################################################################ + +locals { + perps_mm_env_suffix = substr(var.account_shortname, 8, 3) +} + +################################ +# SECURITY GROUPS +################################ + +resource "aws_security_group" "perps_mm_alb_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-alb-${local.perps_mm_env_suffix}" + description = "Security group for Perps Market Maker internal ALB" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTPS from VPC and VPN" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.use1_1.cidr_block, "172.18.0.0/19"] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM ALB Security Group", + Capability = null, + }, + ) +} + +resource "aws_security_group" "perps_mm_ecs_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-ecs-${local.perps_mm_env_suffix}" + description = "Security group for Perps Market Maker ECS tasks" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTP from ALB" + from_port = var.perps_mm_service.cnt_port + to_port = var.perps_mm_service.cnt_port + protocol = "tcp" + security_groups = [aws_security_group.perps_mm_alb_use1[count.index].id] + } + + egress { + description = "Allow all outbound (RPC + chain access)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM ECS Security Group", + Capability = null, + }, + ) +} + +################################ +# CLOUDWATCH LOGS +################################ + +resource "aws_cloudwatch_log_group" "perps_mm_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "/ecs/${local.shortname}-perps-mm-${local.perps_mm_env_suffix}" + retention_in_days = 7 + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM ECS Log Group", + Capability = null, + }, + ) +} + +################################ +# APPLICATION LOAD BALANCER (INTERNAL) +################################ + +resource "aws_alb" "perps_mm_int_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "alb-${local.shortname}-perps-mm-${local.perps_mm_env_suffix}" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.perps_mm_alb_use1[count.index].id] + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + enable_deletion_protection = false + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM Internal ALB", + Capability = null, + }, + ) +} + +resource "aws_alb_target_group" "perps_mm_int_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "tg-${local.shortname}-perps-mm-${local.perps_mm_env_suffix}" + port = tonumber(var.perps_mm_service.cnt_port) + protocol = "HTTP" + vpc_id = data.aws_vpc.use1_1.id + target_type = "ip" + load_balancing_algorithm_type = "round_robin" + deregistration_delay = "10" + + health_check { + enabled = true + interval = 30 + path = "/health" + port = var.perps_mm_service.cnt_port + protocol = "HTTP" + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM Target Group", + Capability = null, + }, + ) +} + +resource "aws_alb_listener" "perps_mm_int_443_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + load_balancer_arn = aws_alb.perps_mm_int_use1[count.index].arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-FS-1-2-Res-2020-10" + certificate_arn = local.hp_acm["exc"].arn + + default_action { + type = "forward" + target_group_arn = aws_alb_target_group.perps_mm_int_use1[count.index].arn + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM HTTPS Listener", + Capability = null, + }, + ) +} + +resource "aws_route53_record" "perps_mm_int_use1" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + zone_id = local.hp_dns["exc"].zone_id + name = "perpsmm.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.perps_mm_int_use1[count.index].dns_name + zone_id = aws_alb.perps_mm_int_use1[count.index].zone_id + evaluate_target_health = true + } +} + +################################ +# ECS SERVICE & TASK +################################ +# desired_count starts at 0; CI/CD scales it up on first deploy. The +# initial task-def revision points at a public busybox stub that just +# sleeps - it is never expected to launch. Both task_definition and +# desired_count are ignored after first apply so subsequent terraform +# applies don't fight CI/CD. +################################ + +resource "aws_ecs_service" "perps_mm_use1" { + lifecycle { ignore_changes = [task_definition, desired_count] } + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "svc-${local.shortname}-perps-mm-${local.perps_mm_env_suffix}" + cluster = data.aws_ecs_cluster.derivatives.arn + task_definition = aws_ecs_task_definition.perps_mm_use1[count.index].arn + desired_count = 0 + launch_type = "FARGATE" + propagate_tags = "SERVICE" + enable_execute_command = true + + # Market maker: only one instance active at a time. Recreate strategy + # avoids duplicate order submissions during deploys. + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + assign_public_ip = false + security_groups = [aws_security_group.perps_mm_ecs_use1[count.index].id] + } + + load_balancer { + target_group_arn = aws_alb_target_group.perps_mm_int_use1[count.index].arn + container_name = "${local.shortname}-perps-mm-container" + container_port = var.perps_mm_service.cnt_port + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM Service", + Capability = null, + }, + ) +} + +resource "aws_ecs_task_definition" "perps_mm_use1" { + lifecycle { ignore_changes = [container_definitions] } + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + family = "tsk-${local.shortname}-perps-mm" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.perps_mm_service.task_cpu + memory = var.perps_mm_service.task_ram + task_role_arn = local.titanio_role_arn + execution_role_arn = local.titanio_role_arn + + # STUB CONTAINER. CI/CD overwrites this on every deploy; the only thing + # that matters here is that the task def is registerable. busybox is + # public on AWS Public ECR (no auth, no rate limits) and has a tiny + # footprint. The container_name and portMappings must match the values + # the ECS service above expects in its load_balancer block. + container_definitions = jsonencode([ + { + name = "${local.shortname}-perps-mm-container" + image = "public.ecr.aws/docker/library/busybox:latest" + command = ["sh", "-c", "echo 'col-mar perps-mm stub - awaiting CI/CD deploy'; sleep infinity"] + cpu = 0 + essential = true + + portMappings = [ + { + containerPort = tonumber(var.perps_mm_service.cnt_port) + hostPort = tonumber(var.perps_mm_service.cnt_port) + protocol = "tcp" + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-create-group" = "true" + "awslogs-group" = aws_cloudwatch_log_group.perps_mm_use1[0].name + "awslogs-region" = var.default_region + "awslogs-stream-prefix" = "${local.shortname}-perps-mm-tsk" + } + } + } + ]) + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Perps MM ECS Task Definition", + Capability = null, + }, + ) +} + +################################ +# ACCESS INFORMATION +################################ +# Endpoint: +# DEV: https://perpsmm.dev.hashpower.exchange/health +# STG: https://perpsmm.stg.hashpower.exchange/health +# LMN: https://perpsmm.hashpower.exchange/health +# +# Access restricted by ALB security group to: +# - VPC CIDR: data.aws_vpc.use1_1.cidr_block +# - VPN CIDR: 172.18.0.0/19 +# +# Architecture: +# perpsmm.{env}.hashpower.exchange (Route53 A record) +# -> Internal ALB (HTTPS:443) +# -> Target Group (health check: /health) +# -> ECS Task (HTTP:cnt_port) diff --git a/.bedrock/.terragrunt/05_futures_mm_mon.tf b/.bedrock/.terragrunt/05_futures_mm_mon.tf new file mode 100644 index 0000000..9a79e4d --- /dev/null +++ b/.bedrock/.terragrunt/05_futures_mm_mon.tf @@ -0,0 +1,744 @@ +################################################################################ +# FUTURES MARKET MAKER — MONITORING +# Metric filters, alarms, and dashboard for the Futures Market Maker ECS service +# +# Mirror of 05_perps_mm_mon.tf with venue-specific names and metric namespace. +# Log message vocabulary is identical between perps and futures (both apps +# share src/core/runner.ts). +################################################################################ + +locals { + futures_mm_metric_ns = "ColMarFuturesMM" +} + +################################################################################ +# SNS TOPIC +################################################################################ + +resource "aws_sns_topic" "futures_mm_alerts" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-alerts-${local.futures_mm_env_suffix}" + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM Alerts" + Capability = "Monitoring" + }) +} + +################################################################################ +# METRIC FILTERS - EVENT COUNTS +################################################################################ + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_halt_count" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-halt-count" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"HALT:*\" }" + + metric_transformation { + name = "HaltCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_error_count" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-error-count" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.level = 50 }" + + metric_transformation { + name = "ErrorCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_warn_count" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-warn-count" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.level = 40 }" + + metric_transformation { + name = "WarnCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_tick_count" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-tick-count" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "TickCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_tick_error" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-tick-error" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick error\" }" + + metric_transformation { + name = "TickErrorCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_gas_throttle" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-gas-throttle" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"throttled:*\" }" + + metric_transformation { + name = "GasThrottleCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_multicall_ok" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-multicall-ok" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"multicall batch executed\" }" + + metric_transformation { + name = "MulticallExecuted" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_multicall_fail" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-multicall-fail" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"multicall batch failed\" }" + + metric_transformation { + name = "MulticallFailed" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_cancel_all_fail" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-cancel-all-fail" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"cancel-all multicall failed\" }" + + metric_transformation { + name = "CancelAllFailed" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_order_matched" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-order-matched" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"own order matched\" }" + + metric_transformation { + name = "OrderMatched" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_price_feed_fail" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-price-feed-fail" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"ETH price feed read failed\" }" + + metric_transformation { + name = "PriceFeedFailed" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_init_retry" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-init-retry" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"initialization failed, retrying\" }" + + metric_transformation { + name = "InitRetryCount" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_cancel_all_triggered" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-cancel-all-triggered" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"cancelling all orders\" }" + + metric_transformation { + name = "CancelAllTriggered" + namespace = local.futures_mm_metric_ns + value = "1" + unit = "Count" + } +} + +################################################################################ +# METRIC FILTERS - VALUES EXTRACTED FROM TICK +################################################################################ + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_collateral" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-collateral" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "CollateralBalance" + namespace = local.futures_mm_metric_ns + value = "$.collateralBalance" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_eth_balance" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-eth-balance" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "EthBalance" + namespace = local.futures_mm_metric_ns + value = "$.ethBalance" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_active_orders" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-active-orders" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "ActiveOrders" + namespace = local.futures_mm_metric_ns + value = "$.orders" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "futures_mm_oracle_price" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-oracle-price" + log_group_name = aws_cloudwatch_log_group.futures_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "OraclePrice" + namespace = local.futures_mm_metric_ns + value = "$.oracle" + default_value = "0" + } +} + +################################################################################ +# ALARMS +################################################################################ + +# CRITICAL - Market maker halted (collateral or daily loss limit) +resource "aws_cloudwatch_metric_alarm" "futures_mm_halt" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-halt-${local.futures_mm_env_suffix}" + alarm_description = "Futures market maker emitted a HALT event - trading stopped" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "HaltCount" + namespace = local.futures_mm_metric_ns + period = 60 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM HALT Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - No heartbeat for 5 minutes (service is down or stuck) +resource "aws_cloudwatch_metric_alarm" "futures_mm_no_tick" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-no-tick-${local.futures_mm_env_suffix}" + alarm_description = "No tick events for 5+ minutes - futures mm may be down" + comparison_operator = "LessThanThreshold" + evaluation_periods = 1 + metric_name = "TickCount" + namespace = local.futures_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "breaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM No Tick Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - Main loop crashing repeatedly +resource "aws_cloudwatch_metric_alarm" "futures_mm_tick_error" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-tick-error-${local.futures_mm_env_suffix}" + alarm_description = "Tick errors repeating - futures main loop is failing" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "TickErrorCount" + namespace = local.futures_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 3 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM Tick Error Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - Cannot cancel orders (exposed position) +resource "aws_cloudwatch_metric_alarm" "futures_mm_cancel_fail" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-cancel-fail-${local.futures_mm_env_suffix}" + alarm_description = "Cancel-all multicall failed - futures orders stuck on-chain" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "CancelAllFailed" + namespace = local.futures_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM Cancel Failure Alarm" + Capability = "Monitoring" + }) +} + +# WARNING - Elevated error rate +resource "aws_cloudwatch_metric_alarm" "futures_mm_error_rate" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-errors-${local.futures_mm_env_suffix}" + alarm_description = "Futures mm error rate elevated (>5 errors in 5 minutes)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ErrorCount" + namespace = local.futures_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 5 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM Error Rate Alarm" + Capability = "Monitoring" + }) +} + +# WARNING - On-chain execution failures +resource "aws_cloudwatch_metric_alarm" "futures_mm_multicall_fail" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-futures-mm-multicall-fail-${local.futures_mm_env_suffix}" + alarm_description = "Multicall batch failed - futures on-chain execution issue" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "MulticallFailed" + namespace = local.futures_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.futures_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Futures MM Multicall Failure Alarm" + Capability = "Monitoring" + }) +} + +################################################################################ +# DASHBOARD +################################################################################ + +resource "aws_cloudwatch_dashboard" "futures_mm" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + dashboard_name = "${local.shortname}-futures-mm-${local.futures_mm_env_suffix}" + + dashboard_body = jsonencode({ + widgets = [ + # Row 1: Health Overview (single-value) + { + type = "metric" + x = 0 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "TickCount", { stat = "Sum", label = "Ticks" }] + ] + view = "singleValue" + region = var.default_region + period = 60 + title = "Ticks / min" + } + }, + { + type = "metric" + x = 4 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "HaltCount", { stat = "Sum", label = "HALTs", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "HALTs (5m)" + } + }, + { + type = "metric" + x = 8 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "ErrorCount", { stat = "Sum", label = "Errors", color = "#ff7f0e" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Errors (5m)" + } + }, + { + type = "metric" + x = 12 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "TickErrorCount", { stat = "Sum", label = "Tick Errors", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Tick Errors (5m)" + } + }, + { + type = "metric" + x = 16 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "ActiveOrders", { stat = "Average", label = "Orders" }] + ] + view = "singleValue" + region = var.default_region + period = 60 + title = "Active Orders" + } + }, + { + type = "metric" + x = 20 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "CancelAllFailed", { stat = "Sum", label = "Cancel Fails", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Cancel Fails (5m)" + } + }, + + # Row 2: Alarm Status + { + type = "alarm" + x = 0 + y = 4 + width = 24 + height = 3 + properties = { + alarms = [ + aws_cloudwatch_metric_alarm.futures_mm_halt[0].arn, + aws_cloudwatch_metric_alarm.futures_mm_no_tick[0].arn, + aws_cloudwatch_metric_alarm.futures_mm_tick_error[0].arn, + aws_cloudwatch_metric_alarm.futures_mm_cancel_fail[0].arn, + aws_cloudwatch_metric_alarm.futures_mm_error_rate[0].arn, + aws_cloudwatch_metric_alarm.futures_mm_multicall_fail[0].arn, + ] + title = "Alarm Status" + } + }, + + # Row 3: Balances & Oracle + { + type = "metric" + x = 0 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "CollateralBalance", { stat = "Average", label = "Collateral (raw)" }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Collateral Balance" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [{ expression = "m1/1000000000000000000", label = "ETH", id = "e1" }], + [local.futures_mm_metric_ns, "EthBalance", { stat = "Average", id = "m1", visible = false }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "ETH Balance" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [{ expression = "m1/100", label = "Oracle ($)", id = "e1" }], + [local.futures_mm_metric_ns, "OraclePrice", { stat = "Average", id = "m1", visible = false }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Oracle Price (ETH/USD)" + yAxis = { left = { min = 0 } } + } + }, + + # Row 4: Trading Activity + { + type = "metric" + x = 0 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "MulticallExecuted", { stat = "Sum", label = "Executed", color = "#2ca02c" }], + [local.futures_mm_metric_ns, "MulticallFailed", { stat = "Sum", label = "Failed", color = "#d62728" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Multicall Batches (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "OrderMatched", { stat = "Sum", label = "Fills", color = "#1f77b4" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Order Fills (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "CancelAllTriggered", { stat = "Sum", label = "Cancel All", color = "#ff7f0e" }], + [local.futures_mm_metric_ns, "CancelAllFailed", { stat = "Sum", label = "Cancel Failed", color = "#d62728" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Cancel All Events (5m)" + yAxis = { left = { min = 0 } } + } + }, + + # Row 5: Infrastructure Health + { + type = "metric" + x = 0 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "WarnCount", { stat = "Sum", label = "Warnings", color = "#ff7f0e" }], + [local.futures_mm_metric_ns, "GasThrottleCount", { stat = "Sum", label = "Gas Throttle", color = "#9467bd" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Warnings & Gas Throttle (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "PriceFeedFailed", { stat = "Sum", label = "Price Feed Fail", color = "#d62728" }], + [local.futures_mm_metric_ns, "InitRetryCount", { stat = "Sum", label = "Init Retries", color = "#ff7f0e" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Price Feed & Init Failures (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.futures_mm_metric_ns, "ActiveOrders", { stat = "Average", label = "Active Orders", color = "#1f77b4" }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Active Orders Over Time" + yAxis = { left = { min = 0 } } + } + }, + + # Row 6: Logs + { + type = "log" + x = 0 + y = 25 + width = 24 + height = 6 + properties = { + query = "SOURCE '${aws_cloudwatch_log_group.futures_mm_use1[0].name}' | fields @timestamp, msg, component, coalesce(message, '') as detail | filter level >= 40 | sort @timestamp desc | limit 50" + region = var.default_region + title = "Recent Errors & Warnings" + view = "table" + } + } + ] + }) +} diff --git a/.bedrock/.terragrunt/05_perps_mm_mon.tf b/.bedrock/.terragrunt/05_perps_mm_mon.tf new file mode 100644 index 0000000..e623975 --- /dev/null +++ b/.bedrock/.terragrunt/05_perps_mm_mon.tf @@ -0,0 +1,759 @@ +################################################################################ +# PERPS MARKET MAKER — MONITORING +# Metric filters, alarms, and dashboard for the Perps Market Maker ECS service +# +# Log format: structured JSON via pino +# level 30 = info, 40 = warn, 50 = error +# +# Key log messages: +# "tick" heartbeat with balances/orders +# "tick error" main loop iteration failed (level 50) +# "HALT: *" risk manager stopped trading (level 50) +# "multicall batch executed" successful on-chain order batch +# "multicall batch failed" failed on-chain execution (level 50) +# "cancel-all multicall failed" can't cancel orders on-chain (level 50) +# "own order matched" one of our orders was filled +# "throttled: *" gas budget exceeded (level 40) +# "ETH price feed read failed" oracle/gas pricing source broken (level 40) +# "initialization failed, *" service can't start (level 40) +# "cancelling all orders" emergency order pull (level 40) +################################################################################ + +locals { + perps_mm_metric_ns = "ColMarPerpsMM" +} + +################################################################################ +# SNS TOPIC +# +# Dedicated topic for perps market maker alerts. Subscribe the devops-alerts +# Lambda (titanio-{env}-dev-alerts -> Slack) externally to route into Slack. +################################################################################ + +resource "aws_sns_topic" "perps_mm_alerts" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-alerts-${local.perps_mm_env_suffix}" + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM Alerts" + Capability = "Monitoring" + }) +} + +################################################################################ +# METRIC FILTERS - EVENT COUNTS +################################################################################ + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_halt_count" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-halt-count" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"HALT:*\" }" + + metric_transformation { + name = "HaltCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_error_count" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-error-count" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.level = 50 }" + + metric_transformation { + name = "ErrorCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_warn_count" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-warn-count" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.level = 40 }" + + metric_transformation { + name = "WarnCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_tick_count" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-tick-count" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "TickCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_tick_error" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-tick-error" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick error\" }" + + metric_transformation { + name = "TickErrorCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_gas_throttle" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-gas-throttle" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"throttled:*\" }" + + metric_transformation { + name = "GasThrottleCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_multicall_ok" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-multicall-ok" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"multicall batch executed\" }" + + metric_transformation { + name = "MulticallExecuted" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_multicall_fail" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-multicall-fail" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"multicall batch failed\" }" + + metric_transformation { + name = "MulticallFailed" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_cancel_all_fail" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-cancel-all-fail" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"cancel-all multicall failed\" }" + + metric_transformation { + name = "CancelAllFailed" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_order_matched" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-order-matched" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"own order matched\" }" + + metric_transformation { + name = "OrderMatched" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_price_feed_fail" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-price-feed-fail" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"ETH price feed read failed\" }" + + metric_transformation { + name = "PriceFeedFailed" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_init_retry" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-init-retry" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"initialization failed, retrying\" }" + + metric_transformation { + name = "InitRetryCount" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_cancel_all_triggered" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-cancel-all-triggered" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"cancelling all orders\" }" + + metric_transformation { + name = "CancelAllTriggered" + namespace = local.perps_mm_metric_ns + value = "1" + unit = "Count" + } +} + +################################################################################ +# METRIC FILTERS - VALUES EXTRACTED FROM TICK +################################################################################ + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_collateral" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-collateral" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "CollateralBalance" + namespace = local.perps_mm_metric_ns + value = "$.collateralBalance" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_eth_balance" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-eth-balance" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "EthBalance" + namespace = local.perps_mm_metric_ns + value = "$.ethBalance" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_active_orders" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-active-orders" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "ActiveOrders" + namespace = local.perps_mm_metric_ns + value = "$.orders" + default_value = "0" + } +} + +resource "aws_cloudwatch_log_metric_filter" "perps_mm_oracle_price" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-oracle-price" + log_group_name = aws_cloudwatch_log_group.perps_mm_use1[0].name + pattern = "{ $.msg = \"tick\" }" + + metric_transformation { + name = "OraclePrice" + namespace = local.perps_mm_metric_ns + value = "$.oracle" + default_value = "0" + } +} + +################################################################################ +# ALARMS +################################################################################ + +# CRITICAL - Market maker halted (collateral or daily loss limit) +resource "aws_cloudwatch_metric_alarm" "perps_mm_halt" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-halt-${local.perps_mm_env_suffix}" + alarm_description = "Perps market maker emitted a HALT event - trading stopped" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "HaltCount" + namespace = local.perps_mm_metric_ns + period = 60 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM HALT Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - No heartbeat for 5 minutes (service is down or stuck) +resource "aws_cloudwatch_metric_alarm" "perps_mm_no_tick" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-no-tick-${local.perps_mm_env_suffix}" + alarm_description = "No tick events for 5+ minutes - perps mm may be down" + comparison_operator = "LessThanThreshold" + evaluation_periods = 1 + metric_name = "TickCount" + namespace = local.perps_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "breaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM No Tick Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - Main loop crashing repeatedly +resource "aws_cloudwatch_metric_alarm" "perps_mm_tick_error" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-tick-error-${local.perps_mm_env_suffix}" + alarm_description = "Tick errors repeating - perps main loop is failing" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "TickErrorCount" + namespace = local.perps_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 3 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM Tick Error Alarm" + Capability = "Monitoring" + }) +} + +# CRITICAL - Cannot cancel orders (exposed position) +resource "aws_cloudwatch_metric_alarm" "perps_mm_cancel_fail" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-cancel-fail-${local.perps_mm_env_suffix}" + alarm_description = "Cancel-all multicall failed - perps orders stuck on-chain" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "CancelAllFailed" + namespace = local.perps_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM Cancel Failure Alarm" + Capability = "Monitoring" + }) +} + +# WARNING - Elevated error rate +resource "aws_cloudwatch_metric_alarm" "perps_mm_error_rate" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-errors-${local.perps_mm_env_suffix}" + alarm_description = "Perps mm error rate elevated (>5 errors in 5 minutes)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ErrorCount" + namespace = local.perps_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 5 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM Error Rate Alarm" + Capability = "Monitoring" + }) +} + +# WARNING - On-chain execution failures +resource "aws_cloudwatch_metric_alarm" "perps_mm_multicall_fail" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + alarm_name = "${local.shortname}-perps-mm-multicall-fail-${local.perps_mm_env_suffix}" + alarm_description = "Multicall batch failed - perps on-chain execution issue" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "MulticallFailed" + namespace = local.perps_mm_metric_ns + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + ok_actions = [aws_sns_topic.perps_mm_alerts[0].arn] + + tags = merge(var.default_tags, var.foundation_tags, { + Name = "Col-Mar Perps MM Multicall Failure Alarm" + Capability = "Monitoring" + }) +} + +################################################################################ +# DASHBOARD +################################################################################ + +resource "aws_cloudwatch_dashboard" "perps_mm" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + dashboard_name = "${local.shortname}-perps-mm-${local.perps_mm_env_suffix}" + + dashboard_body = jsonencode({ + widgets = [ + # Row 1: Health Overview (single-value) + { + type = "metric" + x = 0 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "TickCount", { stat = "Sum", label = "Ticks" }] + ] + view = "singleValue" + region = var.default_region + period = 60 + title = "Ticks / min" + } + }, + { + type = "metric" + x = 4 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "HaltCount", { stat = "Sum", label = "HALTs", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "HALTs (5m)" + } + }, + { + type = "metric" + x = 8 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "ErrorCount", { stat = "Sum", label = "Errors", color = "#ff7f0e" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Errors (5m)" + } + }, + { + type = "metric" + x = 12 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "TickErrorCount", { stat = "Sum", label = "Tick Errors", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Tick Errors (5m)" + } + }, + { + type = "metric" + x = 16 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "ActiveOrders", { stat = "Average", label = "Orders" }] + ] + view = "singleValue" + region = var.default_region + period = 60 + title = "Active Orders" + } + }, + { + type = "metric" + x = 20 + y = 0 + width = 4 + height = 4 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "CancelAllFailed", { stat = "Sum", label = "Cancel Fails", color = "#d62728" }] + ] + view = "singleValue" + region = var.default_region + period = 300 + title = "Cancel Fails (5m)" + } + }, + + # Row 2: Alarm Status + { + type = "alarm" + x = 0 + y = 4 + width = 24 + height = 3 + properties = { + alarms = [ + aws_cloudwatch_metric_alarm.perps_mm_halt[0].arn, + aws_cloudwatch_metric_alarm.perps_mm_no_tick[0].arn, + aws_cloudwatch_metric_alarm.perps_mm_tick_error[0].arn, + aws_cloudwatch_metric_alarm.perps_mm_cancel_fail[0].arn, + aws_cloudwatch_metric_alarm.perps_mm_error_rate[0].arn, + aws_cloudwatch_metric_alarm.perps_mm_multicall_fail[0].arn, + ] + title = "Alarm Status" + } + }, + + # Row 3: Balances & Oracle + { + type = "metric" + x = 0 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "CollateralBalance", { stat = "Average", label = "Collateral (raw)" }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Collateral Balance" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [{ expression = "m1/1000000000000000000", label = "ETH", id = "e1" }], + [local.perps_mm_metric_ns, "EthBalance", { stat = "Average", id = "m1", visible = false }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "ETH Balance" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 7 + width = 8 + height = 6 + properties = { + metrics = [ + [{ expression = "m1/100", label = "Oracle ($)", id = "e1" }], + [local.perps_mm_metric_ns, "OraclePrice", { stat = "Average", id = "m1", visible = false }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Oracle Price (ETH/USD)" + yAxis = { left = { min = 0 } } + } + }, + + # Row 4: Trading Activity + { + type = "metric" + x = 0 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "MulticallExecuted", { stat = "Sum", label = "Executed", color = "#2ca02c" }], + [local.perps_mm_metric_ns, "MulticallFailed", { stat = "Sum", label = "Failed", color = "#d62728" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Multicall Batches (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "OrderMatched", { stat = "Sum", label = "Fills", color = "#1f77b4" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Order Fills (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 13 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "CancelAllTriggered", { stat = "Sum", label = "Cancel All", color = "#ff7f0e" }], + [local.perps_mm_metric_ns, "CancelAllFailed", { stat = "Sum", label = "Cancel Failed", color = "#d62728" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Cancel All Events (5m)" + yAxis = { left = { min = 0 } } + } + }, + + # Row 5: Infrastructure Health + { + type = "metric" + x = 0 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "WarnCount", { stat = "Sum", label = "Warnings", color = "#ff7f0e" }], + [local.perps_mm_metric_ns, "GasThrottleCount", { stat = "Sum", label = "Gas Throttle", color = "#9467bd" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Warnings & Gas Throttle (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 8 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "PriceFeedFailed", { stat = "Sum", label = "Price Feed Fail", color = "#d62728" }], + [local.perps_mm_metric_ns, "InitRetryCount", { stat = "Sum", label = "Init Retries", color = "#ff7f0e" }] + ] + view = "timeSeries" + region = var.default_region + period = 300 + title = "Price Feed & Init Failures (5m)" + yAxis = { left = { min = 0 } } + } + }, + { + type = "metric" + x = 16 + y = 19 + width = 8 + height = 6 + properties = { + metrics = [ + [local.perps_mm_metric_ns, "ActiveOrders", { stat = "Average", label = "Active Orders", color = "#1f77b4" }] + ] + view = "timeSeries" + region = var.default_region + period = 60 + title = "Active Orders Over Time" + yAxis = { left = { min = 0 } } + } + }, + + # Row 6: Logs + { + type = "log" + x = 0 + y = 25 + width = 24 + height = 6 + properties = { + query = "SOURCE '${aws_cloudwatch_log_group.perps_mm_use1[0].name}' | fields @timestamp, msg, component, coalesce(message, '') as detail | filter level >= 40 | sort @timestamp desc | limit 50" + region = var.default_region + title = "Recent Errors & Warnings" + view = "table" + } + } + ] + }) +} diff --git a/.bedrock/02-dev/dnsprovider.tf b/.bedrock/02-dev/dnsprovider.tf new file mode 100644 index 0000000..c1f52e3 --- /dev/null +++ b/.bedrock/02-dev/dnsprovider.tf @@ -0,0 +1,12 @@ +########################## +# DNS Lookup specific profile +########################## +provider "aws" { + alias = "special-dns" + region = "us-east-1" + profile = var.provider_profile # or `titanio-prd` for DNS roots held by Old Prod account or `titanio-net` for DNS roots held by Bedrock + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} + diff --git a/.bedrock/02-dev/terraform.tfvars b/.bedrock/02-dev/terraform.tfvars new file mode 100644 index 0000000..332da61 --- /dev/null +++ b/.bedrock/02-dev/terraform.tfvars @@ -0,0 +1,68 @@ +######################################## +# Service Toggles - SCAFFOLDING ONLY +######################################## +# Runtime config (image, env vars, secrets, addresses) is owned by the +# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. +# The maps below configure ONLY the immutable infrastructure shell. +######################################## + +create_core = true + +# Perps Market Maker - active. Legacy derivatives perps MM was decommissioned +# in 2026-Q2; perpsmm.dev.hashpower.exchange now points at this stack's ALB. +perps_mm_service = { + create = true + task_worker_qty = 1 # initial; CI/CD owns desired_count after first deploy + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +# Futures Market Maker - replaces the futures-marketplace lambda. No DNS +# collision (futuresmm.{env}.hashpower.exchange is fresh). +futures_mm_service = { + create = true + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +######################################## +# Account metadata +######################################## +provider_profile = "titanio-dev" +account_shortname = "titanio-dev" +account_number = "434960487817" +account_lifecycle = "dev" +default_region = "us-east-1" +region_shortname = "use1" + +######################################## +# Environment Specific Variables +######################################## +vpc_index = 1 +devops_keypair = "bedrock-titanio-dev-use1" +titanio_net_edge_vpn = "172.18.16.0/20" +protect_environment = false +ecs_task_role_arn = "arn:aws:iam::434960487817:role/ecsTaskExecutionRole" + +default_tags = { + ServiceOffering = "Cloud Foundation" + Department = "DevOps" + Environment = "dev" + Owner = "aws-titanio-dev@titan.io" + Scope = "Global" + CostCenter = null + Compliance = null + Classification = null + Repository = "https://github.com/Lumerin-protocol/collateral-margin.git//bedrock/02-dev" + ManagedBy = "Terraform" +} + +foundation_tags = { + Name = null + Capability = null + Application = "Lumerin Collateral Margin - DEV" + LifecycleDate = null +} diff --git a/.bedrock/02-dev/terragrunt.hcl b/.bedrock/02-dev/terragrunt.hcl new file mode 100644 index 0000000..53a9143 --- /dev/null +++ b/.bedrock/02-dev/terragrunt.hcl @@ -0,0 +1,3 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/.bedrock/03-stg/dnsprovider.tf b/.bedrock/03-stg/dnsprovider.tf new file mode 100644 index 0000000..c1f52e3 --- /dev/null +++ b/.bedrock/03-stg/dnsprovider.tf @@ -0,0 +1,12 @@ +########################## +# DNS Lookup specific profile +########################## +provider "aws" { + alias = "special-dns" + region = "us-east-1" + profile = var.provider_profile # or `titanio-prd` for DNS roots held by Old Prod account or `titanio-net` for DNS roots held by Bedrock + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} + diff --git a/.bedrock/03-stg/terraform.tfvars b/.bedrock/03-stg/terraform.tfvars new file mode 100644 index 0000000..eb34a12 --- /dev/null +++ b/.bedrock/03-stg/terraform.tfvars @@ -0,0 +1,66 @@ +######################################## +# Service Toggles - SCAFFOLDING ONLY +######################################## +# Runtime config (image, env vars, secrets, addresses) is owned by the +# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. +######################################## + +create_core = true + +# Perps Market Maker - DNS NOTE: perpsmm.stg.hashpower.exchange currently +# belongs to derivatives-marketplace. Leave create=false until cutover. +perps_mm_service = { + create = true + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +# Futures Market Maker +futures_mm_service = { + create = true + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +######################################## +# Account metadata +######################################## +provider_profile = "titanio-stg" +account_shortname = "titanio-stg" +account_number = "464450398935" +account_lifecycle = "stg" +default_region = "us-east-1" +region_shortname = "use1" + +######################################## +# Environment Specific Variables +######################################## +vpc_index = 1 +devops_keypair = "bedrock-titanio-stg-use1" +titanio_net_edge_vpn = "172.18.16.0/20" +protect_environment = false +ecs_task_role_arn = "arn:aws:iam::464450398935:role/ecsTaskExecutionRole" + +default_tags = { + ServiceOffering = "Cloud Foundation" + Department = "DevOps" + Environment = "stg" + Owner = "aws-titanio-stg@titan.io" + Scope = "Global" + CostCenter = null + Compliance = null + Classification = null + Repository = "https://github.com/Lumerin-protocol/collateral-margin.git//bedrock/03-stg" + ManagedBy = "Terraform" +} + +foundation_tags = { + Name = null + Capability = null + Application = "Lumerin Collateral Margin - STG" + LifecycleDate = null +} diff --git a/.bedrock/03-stg/terragrunt.hcl b/.bedrock/03-stg/terragrunt.hcl new file mode 100644 index 0000000..53a9143 --- /dev/null +++ b/.bedrock/03-stg/terragrunt.hcl @@ -0,0 +1,3 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/.bedrock/04-lmn/dnsprovider.tf b/.bedrock/04-lmn/dnsprovider.tf new file mode 100644 index 0000000..c1f52e3 --- /dev/null +++ b/.bedrock/04-lmn/dnsprovider.tf @@ -0,0 +1,12 @@ +########################## +# DNS Lookup specific profile +########################## +provider "aws" { + alias = "special-dns" + region = "us-east-1" + profile = var.provider_profile # or `titanio-prd` for DNS roots held by Old Prod account or `titanio-net` for DNS roots held by Bedrock + ignore_tags { + key_prefixes = ["kubernetes.io/"] + } +} + diff --git a/.bedrock/04-lmn/terraform.tfvars b/.bedrock/04-lmn/terraform.tfvars new file mode 100644 index 0000000..518f251 --- /dev/null +++ b/.bedrock/04-lmn/terraform.tfvars @@ -0,0 +1,66 @@ +######################################## +# Service Toggles - SCAFFOLDING ONLY +######################################## +# Runtime config (image, env vars, secrets, addresses) is owned by the +# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. +######################################## + +create_core = true + +# Perps Market Maker - DNS NOTE: perpsmm.hashpower.exchange currently +# belongs to derivatives-marketplace. Leave create=false until cutover. +perps_mm_service = { + create = true + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +# Futures Market Maker +futures_mm_service = { + create = true + task_worker_qty = 1 + cnt_port = 3001 + task_cpu = 256 + task_ram = 512 +} + +######################################## +# Account metadata +######################################## +provider_profile = "titanio-lmn" +account_shortname = "titanio-lmn" +account_number = "330280307271" +account_lifecycle = "prd" +default_region = "us-east-1" +region_shortname = "use1" + +######################################## +# Environment Specific Variables +######################################## +vpc_index = 1 +devops_keypair = "bedrock-titanio-lmn-use1" +titanio_net_edge_vpn = "172.18.16.0/20" +protect_environment = false +ecs_task_role_arn = "arn:aws:iam::330280307271:role/ecsTaskExecutionRole" + +default_tags = { + ServiceOffering = "Cloud Foundation" + Department = "DevOps" + Environment = "lmn" + Owner = "aws-titanio-lmn@titan.io" + Scope = "Global" + CostCenter = null + Compliance = null + Classification = null + Repository = "https://github.com/Lumerin-protocol/collateral-margin.git//bedrock/04-lmn" + ManagedBy = "Terraform" +} + +foundation_tags = { + Name = null + Capability = null + Application = "Lumerin Collateral Margin - LMN" + LifecycleDate = null +} diff --git a/.bedrock/04-lmn/terragrunt.hcl b/.bedrock/04-lmn/terragrunt.hcl new file mode 100644 index 0000000..53a9143 --- /dev/null +++ b/.bedrock/04-lmn/terragrunt.hcl @@ -0,0 +1,3 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/.bedrock/root.hcl b/.bedrock/root.hcl new file mode 100644 index 0000000..a42f3d2 --- /dev/null +++ b/.bedrock/root.hcl @@ -0,0 +1,21 @@ +remote_state { + backend = "s3" + generate = { + path = "00_TG_bedrock_init.tf" + if_exists = "overwrite_terragrunt" + } + config = { + profile = "titanio-mst" + bucket = "titanio-terraform-states" + use_lockfile = true + key = "state/titanio/afs/collateral-margin/${substr(path_relative_to_include(),3, 3)}.tfstate" + region = "us-east-1" + encrypt = true + kms_key_id = "arn:aws:kms:us-east-1:228930573471:alias/foundation-cmk-s3" + acl = "bucket-owner-full-control" + } +} + +terraform { + source = "../.terragrunt/" +} \ No newline at end of file diff --git a/.cursorignore b/.cursorignore index 59fb312..a1abc27 100644 --- a/.cursorignore +++ b/.cursorignore @@ -1,3 +1,11 @@ .env .env.* -!.env.example \ No newline at end of file +!.env.example +# Terraform / Terragrunt +.terraform +.terraform.lock.hcl +.terragrunt-cache +*.out +*.plan +secret.tfvars +secret.* \ No newline at end of file diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml new file mode 100644 index 0000000..372222f --- /dev/null +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -0,0 +1,470 @@ +name: Deploy Collateral Margin Market Maker + +# CI/CD owns the "personality" of the perps and futures market-maker ECS +# services. Terraform builds the scaffolding (service shell, ALB, target +# group, log group, security groups, Route53, IAM); this workflow: +# +# 1. Builds one Docker image from market-maker/ and pushes to GHCR +# 2. For each venue (perps, futures), renders a new task-def revision +# with image + env vars + secrets sourced from GitHub Variables and +# Secrets (no AWS Secrets Manager) +# 3. Calls ecs:UpdateService to point both services at the new revisions +# and to scale them to the operator-chosen desired_count +# +# GitHub Variables (configure per-environment under Settings -> Environments): +# MAKER_LOG_LEVEL default "info" +# MAKER_DRY_RUN default "false" +# MAKER_CANCEL_ORDERS_ON_SHUTDOWN default "true" +# MAKER_HEALTH_PORT default "3001" (must match TF cnt_port) +# MAKER_DESIRED_COUNT default "1" (set 0 to halt without redeploy) +# PERPS_ADDRESS contract address (perps service only) +# FUTURES_ADDRESS contract address (futures service only) +# ETH_PRICE_FEED_ADDRESS optional Chainlink feed +# +# GitHub Secrets (configure per-environment): +# AWS_ROLE_ARN_DEV / _STG / _LMN OIDC role ARNs from the TF output github_actions_role_arn +# ALCHEMY_API_KEY shared Alchemy project key (composes the RPC URL) +# PERPS_MM_PRIVATE_KEY perps wallet private key +# FUTURES_MM_PRIVATE_KEY futures wallet private key +# SLACK_WEBHOOK_URL (org or repo level) for slack-notify + +on: + push: + branches: + - dev + - stg + - main + - 'cicd/**' + paths: + - 'market-maker/**' + - '.github/workflows/deploy-col-mar-mm.yml' + workflow_dispatch: + inputs: + environment: + description: 'Target environment (dev=DEV, stg=STG, main=LMN/PROD)' + required: true + type: choice + options: + - dev + - stg + - main + +permissions: + id-token: write # Required for OIDC + contents: write # Required for creating git tags on main + packages: write # For GHCR + +env: + COMPONENT: col-mar-mm + GHCR_REGISTRY: ghcr.io + GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-market-maker + +jobs: + build: + name: 🔨 Build + runs-on: ubuntu-latest + outputs: + version: ${{ steps.gen_tag.outputs.version }} + tag: ${{ steps.gen_tag.outputs.tag_name }} + environment: ${{ steps.gen_tag.outputs.environment }} + maker_env: ${{ steps.env_config.outputs.maker_env }} + env_suffix: ${{ steps.env_config.outputs.env_suffix }} + aws_region: ${{ steps.env_config.outputs.aws_region }} + ecs_cluster: ${{ steps.env_config.outputs.ecs_cluster }} + perps_service: ${{ steps.env_config.outputs.perps_service }} + futures_service: ${{ steps.env_config.outputs.futures_service }} + perps_task_family: ${{ steps.env_config.outputs.perps_task_family }} + futures_task_family: ${{ steps.env_config.outputs.futures_task_family }} + is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Generate version tag + id: gen_tag + uses: ./.github/actions/gen-tag + with: + component: col-mar-mm + major_version: '1' + environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + + - name: Environment config + id: env_config + run: | + ENV="${{ steps.gen_tag.outputs.environment }}" + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "perps_task_family=tsk-col-mar-perps-mm" >> $GITHUB_OUTPUT + echo "futures_task_family=tsk-col-mar-futures-mm" >> $GITHUB_OUTPUT + + case "$ENV" in + dev) + SUFFIX="dev" + MAKER_ENV="dev" + ;; + stg) + SUFFIX="stg" + MAKER_ENV="stg" + ;; + main) + SUFFIX="lmn" + MAKER_ENV="prd" + ;; + *) + echo "::error::Unknown environment '$ENV'" + exit 1 + ;; + esac + + echo "env_suffix=${SUFFIX}" >> $GITHUB_OUTPUT + echo "maker_env=${MAKER_ENV}" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-derivatives-marketplace-${SUFFIX}" >> $GITHUB_OUTPUT + echo "perps_service=svc-col-mar-perps-mm-${SUFFIX}" >> $GITHUB_OUTPUT + echo "futures_service=svc-col-mar-futures-mm-${SUFFIX}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker tags + id: docker_tags + run: | + TAGS="${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.version }} + ${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.environment }}-latest" + + if [ "${{ steps.gen_tag.outputs.environment }}" == "main" ]; then + TAGS="${TAGS} + ${{ env.GHCR_IMAGE }}:latest" + fi + + echo "tags<> $GITHUB_OUTPUT + echo "$TAGS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./market-maker + push: ${{ steps.gen_tag.outputs.is_cicd_branch != 'true' }} + load: ${{ steps.gen_tag.outputs.is_cicd_branch == 'true' }} + tags: ${{ steps.docker_tags.outputs.tags }} + build-args: | + COMMIT_HASH=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + labels: | + org.opencontainers.image.source=${{ github.repositoryUrl }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.gen_tag.outputs.version }} + + - name: CI/CD test summary + if: steps.gen_tag.outputs.is_cicd_branch == 'true' + run: | + echo "## 🔧 CI/CD Test Build Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** Test only (no deployment)" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.gen_tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Image:** Built locally, not pushed to GHCR" >> $GITHUB_STEP_SUMMARY + + deploy: + name: 🚀 Deploy ${{ matrix.app }} + runs-on: ubuntu-latest + needs: build + if: needs.build.outputs.is_cicd_branch != 'true' + environment: ${{ needs.build.outputs.environment }} + strategy: + fail-fast: false + matrix: + app: [perps, futures] + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-${{ github.run_id }} + + - name: Verify service exists + id: svc_check + env: + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + REGION: ${{ needs.build.outputs.aws_region }} + run: | + ACTIVE_COUNT=$(aws ecs describe-services \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" \ + --query 'services[?status==`ACTIVE`] | length(@)' \ + --output text 2>/dev/null || echo "0") + + if [ "$ACTIVE_COUNT" = "0" ]; then + echo "⚠️ ECS service '$SERVICE' is not ACTIVE in cluster '$CLUSTER'." + echo " Likely Terraform has create=false for this venue. Skipping deploy." + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "✅ Service '$SERVICE' is active. Proceeding with deploy." + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Render new task definition + deploy + if: steps.svc_check.outputs.skip != 'true' + env: + # Routing + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} + TASK_FAMILY: ${{ matrix.app == 'perps' && needs.build.outputs.perps_task_family || needs.build.outputs.futures_task_family }} + REGION: ${{ needs.build.outputs.aws_region }} + IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} + + # Per-environment GitHub Variables (non-secret personality) + MAKER_LOG_LEVEL: ${{ vars.MAKER_LOG_LEVEL }} + MAKER_DRY_RUN: ${{ vars.MAKER_DRY_RUN }} + MAKER_CANCEL_ORDERS_ON_SHUTDOWN: ${{ vars.MAKER_CANCEL_ORDERS_ON_SHUTDOWN }} + MAKER_HEALTH_PORT: ${{ vars.MAKER_HEALTH_PORT }} + MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} + ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} + + # Per-environment GitHub Secrets (encrypted personality). Secrets + # only flow into this step's process env via inline ternaries; they + # are never written to step outputs or to disk on the runner. + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + PRIVATE_KEY: ${{ matrix.app == 'perps' && secrets.PERPS_MM_PRIVATE_KEY || secrets.FUTURES_MM_PRIVATE_KEY }} + + # Per-venue config selected by matrix.app + VENUE_ADDR_NAME: ${{ matrix.app == 'perps' && 'PERPS_ADDRESS' || 'FUTURES_ADDRESS' }} + VENUE_ADDR_VALUE: ${{ matrix.app == 'perps' && vars.PERPS_ADDRESS || vars.FUTURES_ADDRESS }} + + # Computed + MAKER_APP: ${{ matrix.app }} + MAKER_ENV: ${{ needs.build.outputs.maker_env }} + COMMIT_HASH: ${{ github.sha }} + run: | + set -euo pipefail + DESIRED_COUNT="${MAKER_DESIRED_COUNT:-1}" + + echo "🚀 Deploying ${{ matrix.app }} to ${{ needs.build.outputs.environment }}" + echo " Cluster: ${CLUSTER}" + echo " Service: ${SERVICE}" + echo " Task Family: ${TASK_FAMILY}" + echo " Image: ${IMAGE}" + echo " MAKER_ENV: ${MAKER_ENV}" + echo " Desired Count: ${DESIRED_COUNT}" + + # Fetch current task def, strip non-registerable metadata, swap image + echo "📥 Fetching current task definition..." + aws ecs describe-task-definition \ + --task-definition "${TASK_FAMILY}" \ + --region "${REGION}" \ + --query 'taskDefinition' > task-def.json + + echo "🔧 Updating container image..." + jq --arg IMAGE "${IMAGE}" ' + .containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy) + ' task-def.json > new-task-def.json + + # Build the full env block from scratch. Personality lives ONLY here; + # the Terraform stub deliberately ships zero env vars. + echo "🔧 Injecting environment from GitHub Variables / Secrets..." + jq -n \ + --arg MAKER_APP "${MAKER_APP}" \ + --arg MAKER_ENV "${MAKER_ENV}" \ + --arg MAKER_HEALTH_PORT "${MAKER_HEALTH_PORT:-3001}" \ + --arg MAKER_LOG_LEVEL "${MAKER_LOG_LEVEL:-info}" \ + --arg MAKER_DRY_RUN "${MAKER_DRY_RUN:-false}" \ + --arg MAKER_CANCEL_ORDERS_ON_SHUTDOWN "${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true}" \ + --arg COMMIT_HASH "${COMMIT_HASH}" \ + --arg ETH_PRICE_FEED_ADDRESS "${ETH_PRICE_FEED_ADDRESS:-}" \ + --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ + --arg PRIVATE_KEY "${PRIVATE_KEY}" \ + --arg VENUE_ADDR_NAME "${VENUE_ADDR_NAME}" \ + --arg VENUE_ADDR_VALUE "${VENUE_ADDR_VALUE}" \ + '[ + {name:"MAKER_APP", value:$MAKER_APP}, + {name:"MAKER_ENV", value:$MAKER_ENV}, + {name:"MAKER_HEALTH_PORT", value:$MAKER_HEALTH_PORT}, + {name:"MAKER_LOG_LEVEL", value:$MAKER_LOG_LEVEL}, + {name:"MAKER_DRY_RUN", value:$MAKER_DRY_RUN}, + {name:"MAKER_CANCEL_ORDERS_ON_SHUTDOWN", value:$MAKER_CANCEL_ORDERS_ON_SHUTDOWN}, + {name:"COMMIT_HASH", value:$COMMIT_HASH}, + {name:"ETH_PRICE_FEED_ADDRESS", value:$ETH_PRICE_FEED_ADDRESS}, + {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, + {name:"PRIVATE_KEY", value:$PRIVATE_KEY}, + {name:$VENUE_ADDR_NAME, value:$VENUE_ADDR_VALUE} + ]' > env-block.json + + # Replace the entire env block; also drop any stale `secrets` block + # (we no longer use Secrets Manager). + jq --slurpfile env env-block.json ' + .containerDefinitions[0].environment = $env[0] | + del(.containerDefinitions[0].secrets) + ' new-task-def.json > final-task-def.json + mv final-task-def.json new-task-def.json + + # Register new revision + echo "📝 Registering new task definition revision..." + NEW_TASK_DEF=$(aws ecs register-task-definition \ + --cli-input-json file://new-task-def.json \ + --region "${REGION}" \ + --query 'taskDefinition.taskDefinitionArn' --output text) + echo "✅ Registered: ${NEW_TASK_DEF}" + + # Update ECS service to point at the new revision and the desired count + echo "🚀 Updating ECS service..." + aws ecs update-service \ + --cluster "${CLUSTER}" \ + --service "${SERVICE}" \ + --task-definition "${NEW_TASK_DEF}" \ + --desired-count "${DESIRED_COUNT}" \ + --region "${REGION}" \ + --force-new-deployment > /dev/null + + echo "✅ Deployment triggered for ${{ matrix.app }}" + + verify: + name: 🔍 Verify ${{ matrix.app }} + runs-on: ubuntu-latest + needs: [build, deploy] + if: needs.build.outputs.is_cicd_branch != 'true' + strategy: + fail-fast: false + matrix: + app: [perps, futures] + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-Verify-${{ github.run_id }} + + - name: Wait for service to stabilize + env: + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + REGION: ${{ needs.build.outputs.aws_region }} + run: | + ACTIVE=$(aws ecs describe-services \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" \ + --query 'services[?status==`ACTIVE`] | length(@)' \ + --output text 2>/dev/null || echo "0") + if [ "$ACTIVE" = "0" ]; then + echo "⚠️ Service $SERVICE not active — nothing to verify." + exit 0 + fi + + echo "⏳ Waiting for $SERVICE to stabilize..." + aws ecs wait services-stable \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" + echo "✅ Stable." + + cleanup: + name: 🧹 Cleanup + runs-on: ubuntu-latest + needs: [build, verify] + if: always() && needs.build.outputs.is_cicd_branch != 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + if: needs.verify.result == 'success' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: needs.verify.result == 'success' + run: | + TAG_NAME="${{ needs.build.outputs.tag }}" + echo "🏷️ Creating tag: $TAG_NAME" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "⚠️ Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${{ needs.build.outputs.version }} - Deployed to ${{ needs.build.outputs.environment }}" + git push origin "$TAG_NAME" + echo "✅ Tag pushed" + fi + + - name: Deployment summary + if: needs.verify.result == 'success' + run: | + echo "## 🎉 Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Service:** Collateral Margin Market Maker (perps + futures)" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ needs.build.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Image:** \`${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Cluster:** ${{ needs.build.outputs.ecs_cluster }}" >> $GITHUB_STEP_SUMMARY + echo "**Services:** \`${{ needs.build.outputs.perps_service }}\`, \`${{ needs.build.outputs.futures_service }}\`" >> $GITHUB_STEP_SUMMARY + + - name: Failure summary + if: needs.verify.result == 'failure' + run: | + echo "## ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Service:** Collateral Margin Market Maker" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ needs.build.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ Check logs above for details." >> $GITHUB_STEP_SUMMARY + + notify: + name: 📢 Notify + runs-on: ubuntu-latest + needs: [build, deploy, verify, cleanup] + if: always() && (needs.build.result == 'success') + + steps: + - name: Checkout (for composite action) + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine status + id: status + run: | + if [ "${{ needs.build.outputs.is_cicd_branch }}" == "true" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "success" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "failure" ] || [ "${{ needs.deploy.result }}" == "failure" ]; then + echo "status=failure" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "cancelled" ] || [ "${{ needs.deploy.result }}" == "cancelled" ]; then + echo "status=cancelled" >> $GITHUB_OUTPUT + else + echo "status=skipped" >> $GITHUB_OUTPUT + fi + + - name: Send Slack notification + uses: ./.github/actions/slack-notify + with: + status: ${{ steps.status.outputs.status }} + environment: ${{ needs.build.outputs.environment }} + service_name: 'Collateral Margin Market Maker' + version: ${{ needs.build.outputs.version }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} + additional_info: '${{ needs.build.outputs.is_cicd_branch == ''true'' && ''*Mode:* CI/CD Test (build only, no deploy)'' || format(''*Cluster:* `{0}` • *Services:* `{1}`, `{2}`'', needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service) }}' diff --git a/.gitignore b/.gitignore index f913390..42723cd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,13 @@ node_modules .env .env.* !.env.example -.DS_Store \ No newline at end of file +.DS_Store + +# Terraform / Terragrunt +.terraform +.terraform.lock.hcl +.terragrunt-cache +*.out +*.plan +secret.tfvars +secret.* \ No newline at end of file From ec4b9ba9108b4f579d5b437042c5426ac67ff19b Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 5 May 2026 14:25:08 -0500 Subject: [PATCH 009/180] fix: update CICD trigger version for market maker --- market-maker/.cicd_trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/market-maker/.cicd_trigger b/market-maker/.cicd_trigger index df26afb..6935ae8 100644 --- a/market-maker/.cicd_trigger +++ b/market-maker/.cicd_trigger @@ -1 +1 @@ -17744633302 +17744633303 From ade3b019fc8ef54e004ad9874c89036a730b743a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 23:33:31 +0200 Subject: [PATCH 010/180] fix: update dockerfile, fix type stripping - Updated Dockerfile to use Node 24-alpine for dependencies and runtime. - Modified docker-entrypoint.sh to include TypeScript conditions in the Node execution. - Enhanced package.json scripts to utilize pnpm and added watch functionality for local development. --- market-maker/Dockerfile | 4 ++-- market-maker/docker-entrypoint.sh | 2 +- market-maker/package.json | 26 ++++++++++++++------------ market-maker/schemas/futures.json | 3 --- market-maker/schemas/perps.json | 3 --- market-maker/src/core/config/base.ts | 10 ++++++---- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/market-maker/Dockerfile b/market-maker/Dockerfile index af91ad7..1c817c0 100644 --- a/market-maker/Dockerfile +++ b/market-maker/Dockerfile @@ -1,5 +1,5 @@ # ── Install deps ────────────────────────────────────────────────────────────── -FROM node:22-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /app @@ -9,7 +9,7 @@ COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── -FROM node:22-alpine +FROM node:24-alpine ARG COMMIT_HASH="" ENV NODE_ENV=production diff --git a/market-maker/docker-entrypoint.sh b/market-maker/docker-entrypoint.sh index 818e31a..2b57ff8 100755 --- a/market-maker/docker-entrypoint.sh +++ b/market-maker/docker-entrypoint.sh @@ -44,4 +44,4 @@ if [ -z "${MAKER_CONFIG:-}" ]; then export MAKER_CONFIG="/app/configs/${MAKER_APP}.${MAKER_ENV}.yml" fi -exec node "$ENTRY" "$@" +exec node --import=amaro/strip --conditions=typescript "$ENTRY" "$@" diff --git a/market-maker/package.json b/market-maker/package.json index adec1fd..b301630 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -7,22 +7,24 @@ "node": ">=22.6.0" }, "scripts": { - "test": "node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", "typecheck": "tsc --noEmit", - "local:perps": "node src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", - "local:futures": "node src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", - "dev:perps": "node src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", - "dev:futures": "node src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", - "stg:perps": "node src/apps/perps/main.ts --config configs/perps.stg.yml", - "stg:futures": "node src/apps/futures/main.ts --config configs/futures.stg.yml", - "prd:perps": "node src/apps/perps/main.ts --config configs/perps.prd.yml", - "prd:futures": "node src/apps/futures/main.ts --config configs/futures.prd.yml", - "gen:schemas": "node scripts/gen-schemas.ts", - "pretypecheck": "node scripts/gen-schemas.ts" + "node": "node --import=amaro/strip --conditions=typescript", + "local:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", + "local:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", + "dev:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", + "dev:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", + "stg:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.stg.yml", + "stg:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.stg.yml", + "prd:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.prd.yml", + "prd:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.prd.yml", + "gen:schemas": "pnpm node scripts/gen-schemas.ts", + "pretypecheck": "pnpm node scripts/gen-schemas.ts" }, "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "amaro": "^1.1.9", "collateral-margin-contracts": "link:../contracts", "fraction.js": "^5.2.2", "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", @@ -39,4 +41,4 @@ "typescript": "^5.8.0" }, "packageManager": "pnpm@10.28.1" -} +} \ No newline at end of file diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 9c57420..9e1842e 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -681,7 +681,6 @@ ], "properties": { "pollIntervalSec": { - "minimum": 0.1, "default": 3, "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ @@ -729,7 +728,6 @@ "default": 2 }, "requoteCooldownSec": { - "minimum": 0, "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ @@ -762,7 +760,6 @@ ] }, "resyncIntervalSec": { - "minimum": 1, "default": 60, "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 4525883..3f7f878 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -658,7 +658,6 @@ ], "properties": { "pollIntervalSec": { - "minimum": 0.1, "default": 3, "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ @@ -706,7 +705,6 @@ "default": 2 }, "requoteCooldownSec": { - "minimum": 0, "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ @@ -739,7 +737,6 @@ ] }, "resyncIntervalSec": { - "minimum": 1, "default": 60, "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index ca30975..e26087b 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -18,11 +18,13 @@ const TypeUsdAmount = (opts?: { default?: string | number; description?: string ); /** Schema fragment that accepts a non-negative seconds value (string or number). */ -const TypeSeconds = (opts?: { minimum?: number; default?: string | number; description?: string }) => - Type.Union( - [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum: opts?.minimum })], - opts as Record | undefined, +const TypeSeconds = (opts?: { minimum?: number; default?: string | number; description?: string }) => { + const { minimum, ...unionOpts } = opts ?? {}; + return Type.Union( + [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum })], + unionOpts as Record, ); +}; /** * Shared config schema fragments used by per-app config modules. From 92b8f607bbe7fc8c7d01744b1be122e4ef593979 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 5 May 2026 23:33:31 +0200 Subject: [PATCH 011/180] fix: update dockerfile, fix type stripping - Updated Dockerfile to use Node 24-alpine for dependencies and runtime. - Modified docker-entrypoint.sh to include TypeScript conditions in the Node execution. - Enhanced package.json scripts to utilize pnpm and added watch functionality for local development. --- market-maker/Dockerfile | 4 ++-- market-maker/docker-entrypoint.sh | 2 +- market-maker/package.json | 26 ++++++++++++++------------ market-maker/pnpm-lock.yaml | 19 ++++++++++++++----- market-maker/schemas/futures.json | 3 --- market-maker/schemas/perps.json | 3 --- market-maker/src/core/config/base.ts | 10 ++++++---- 7 files changed, 37 insertions(+), 30 deletions(-) diff --git a/market-maker/Dockerfile b/market-maker/Dockerfile index af91ad7..1c817c0 100644 --- a/market-maker/Dockerfile +++ b/market-maker/Dockerfile @@ -1,5 +1,5 @@ # ── Install deps ────────────────────────────────────────────────────────────── -FROM node:22-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /app @@ -9,7 +9,7 @@ COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── -FROM node:22-alpine +FROM node:24-alpine ARG COMMIT_HASH="" ENV NODE_ENV=production diff --git a/market-maker/docker-entrypoint.sh b/market-maker/docker-entrypoint.sh index 818e31a..2b57ff8 100755 --- a/market-maker/docker-entrypoint.sh +++ b/market-maker/docker-entrypoint.sh @@ -44,4 +44,4 @@ if [ -z "${MAKER_CONFIG:-}" ]; then export MAKER_CONFIG="/app/configs/${MAKER_APP}.${MAKER_ENV}.yml" fi -exec node "$ENTRY" "$@" +exec node --import=amaro/strip --conditions=typescript "$ENTRY" "$@" diff --git a/market-maker/package.json b/market-maker/package.json index adec1fd..b301630 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -7,22 +7,24 @@ "node": ">=22.6.0" }, "scripts": { - "test": "node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", "typecheck": "tsc --noEmit", - "local:perps": "node src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", - "local:futures": "node src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", - "dev:perps": "node src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", - "dev:futures": "node src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", - "stg:perps": "node src/apps/perps/main.ts --config configs/perps.stg.yml", - "stg:futures": "node src/apps/futures/main.ts --config configs/futures.stg.yml", - "prd:perps": "node src/apps/perps/main.ts --config configs/perps.prd.yml", - "prd:futures": "node src/apps/futures/main.ts --config configs/futures.prd.yml", - "gen:schemas": "node scripts/gen-schemas.ts", - "pretypecheck": "node scripts/gen-schemas.ts" + "node": "node --import=amaro/strip --conditions=typescript", + "local:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", + "local:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", + "dev:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", + "dev:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", + "stg:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.stg.yml", + "stg:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.stg.yml", + "prd:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.prd.yml", + "prd:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.prd.yml", + "gen:schemas": "pnpm node scripts/gen-schemas.ts", + "pretypecheck": "pnpm node scripts/gen-schemas.ts" }, "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "amaro": "^1.1.9", "collateral-margin-contracts": "link:../contracts", "fraction.js": "^5.2.2", "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", @@ -39,4 +41,4 @@ "typescript": "^5.8.0" }, "packageManager": "pnpm@10.28.1" -} +} \ No newline at end of file diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 669b4c6..a2b07e4 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.18.0) + amaro: + specifier: ^1.1.9 + version: 1.1.9 collateral-margin-contracts: specifier: link:../contracts version: link:../contracts @@ -970,6 +973,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + amaro@1.1.9: + resolution: {integrity: sha512-Qx5+iHi3mKWz95XNx/WPFl8yRMZEGNoRZDaOkoej72kxAo20FbDVx7jALcvyOn/N3+h+GboKip49yba7xqLlKA==} + engines: {node: '>=22'} + amazon-cognito-identity-js@6.3.16: resolution: {integrity: sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==} @@ -1179,8 +1186,8 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f} version: 1.0.0 color-convert@2.0.1: @@ -4126,6 +4133,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + amaro@1.1.9: {} + amazon-cognito-identity-js@6.3.16: dependencies: '@aws-crypto/sha256-js': 1.2.2 @@ -4363,7 +4372,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f: {} color-convert@2.0.1: dependencies: @@ -4453,7 +4462,7 @@ snapshots: '@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/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 @@ -4736,7 +4745,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8c34d46aba892c0e9ca272ebb4c0c0c95fcaadd8 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 9c57420..9e1842e 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -681,7 +681,6 @@ ], "properties": { "pollIntervalSec": { - "minimum": 0.1, "default": 3, "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ @@ -729,7 +728,6 @@ "default": 2 }, "requoteCooldownSec": { - "minimum": 0, "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ @@ -762,7 +760,6 @@ ] }, "resyncIntervalSec": { - "minimum": 1, "default": 60, "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 4525883..3f7f878 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -658,7 +658,6 @@ ], "properties": { "pollIntervalSec": { - "minimum": 0.1, "default": 3, "description": "Seconds between main-loop iterations (snapshot, quote, execute).", "anyOf": [ @@ -706,7 +705,6 @@ "default": 2 }, "requoteCooldownSec": { - "minimum": 0, "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", "anyOf": [ @@ -739,7 +737,6 @@ ] }, "resyncIntervalSec": { - "minimum": 1, "default": 60, "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", "anyOf": [ diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index ca30975..e26087b 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -18,11 +18,13 @@ const TypeUsdAmount = (opts?: { default?: string | number; description?: string ); /** Schema fragment that accepts a non-negative seconds value (string or number). */ -const TypeSeconds = (opts?: { minimum?: number; default?: string | number; description?: string }) => - Type.Union( - [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum: opts?.minimum })], - opts as Record | undefined, +const TypeSeconds = (opts?: { minimum?: number; default?: string | number; description?: string }) => { + const { minimum, ...unionOpts } = opts ?? {}; + return Type.Union( + [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum })], + unionOpts as Record, ); +}; /** * Shared config schema fragments used by per-app config modules. From 108c5793c82ea34087d38bc8b2e9fa3126b27159 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 14:57:06 +0200 Subject: [PATCH 012/180] fix: dependencies for docker build - Changed the dependency for `collateral-margin-contracts` to point to the GitHub repository. - Added `@sinclair/typebox` to dependencies in `package.json`. - Updated `pnpm-lock.yaml` to reflect the new dependency structure and versions. - Included additional configurations for `pnpm` to manage built dependencies. --- market-maker/package.json | 14 ++++++--- market-maker/pnpm-lock.yaml | 61 +++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/market-maker/package.json b/market-maker/package.json index b301630..2e37467 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -25,20 +25,26 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amaro": "^1.1.9", - "collateral-margin-contracts": "link:../contracts", + "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts", "fraction.js": "^5.2.2", "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", "js-yaml": "^4.1.0", "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts", "pino": "^10.3.1", - "viem": "^2.45.3" + "viem": "^2.45.3", + "@sinclair/typebox": "^0.34.49" }, "devDependencies": { - "@sinclair/typebox": "^0.34.49", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "pino-pretty": "^13.1.3", "typescript": "^5.8.0" }, - "packageManager": "pnpm@10.28.1" + "packageManager": "pnpm@10.28.1", + "pnpm": { + "onlyBuiltDependencies": [ + "keccak@3.0.4", + "secp256k1@4.0.4" + ] + } } \ No newline at end of file diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index a2b07e4..a52021e 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 ajv: specifier: ^8.17.1 version: 8.18.0 @@ -18,8 +21,8 @@ importers: specifier: ^1.1.9 version: 1.1.9 collateral-margin-contracts: - specifier: link:../contracts - version: link:../contracts + specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3) fraction.js: specifier: ^5.2.2 version: 5.3.4 @@ -39,9 +42,6 @@ importers: specifier: ^2.45.3 version: 2.48.4(typescript@5.9.3) devDependencies: - '@sinclair/typebox': - specifier: ^0.34.49 - version: 0.34.49 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -450,6 +450,10 @@ 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} @@ -1186,8 +1190,13 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f} + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc} + version: 1.0.0 + engines: {node: 24.x} + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc} version: 1.0.0 color-convert@2.0.1: @@ -1290,6 +1299,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1846,10 +1859,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3414,6 +3423,8 @@ 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': @@ -4372,7 +4383,19 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f: {} + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3): + dependencies: + '@openzeppelin/contracts': 5.1.0 + '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) + dotenv: 16.6.1 + viem: 2.48.4(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc: {} color-convert@2.0.1: dependencies: @@ -4458,13 +4481,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/multicall3': https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@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/895a307ff10795aaa61e2a67cab8ac760edb328f + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4486,6 +4509,8 @@ snapshots: dependencies: path-type: 4.0.0 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4745,9 +4770,9 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/895a307ff10795aaa61e2a67cab8ac760edb328f + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -5162,8 +5187,6 @@ 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: {} From e933f825fb5f5fe883c143aed52c97602b27cea4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 15:23:42 +0200 Subject: [PATCH 013/180] refactor: replace config summary with sanitised version in health checks - Updated the HealthCheck implementation in both futures and perps applications to use the new sanitiseConfig function, ensuring sensitive information is redacted in the health output. - Removed the summariseConfig function as it is no longer needed. - Enhanced the sanitiseConfig function to redact private keys and mask RPC URLs for security. --- market-maker/src/apps/futures/main.ts | 19 +--- market-maker/src/apps/perps/main.ts | 19 +--- market-maker/src/core/config/base.ts | 52 ++++++++++ market-maker/src/core/healthcheck.ts | 134 +++++++++++++++----------- 4 files changed, 135 insertions(+), 89 deletions(-) diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index e3aa2ea..d204d37 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -14,6 +14,7 @@ import { HealthCheck } from "../../core/healthcheck.ts"; import { runMakerLoop } from "../../core/runner.ts"; import { serializeError } from "../../core/errSerializer.ts"; import { createFuturesVenue } from "../../adapters/futures/index.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; import { loadFuturesConfig } from "./config.ts"; async function main(): Promise { @@ -128,7 +129,7 @@ async function main(): Promise { const health = new HealthCheck({ port: config.health.port, appName: "futures-mm", - configSummary: summariseConfig(config), + configSummary: sanitiseConfig(config), oracle, inventory, collateral, @@ -155,20 +156,4 @@ async function main(): Promise { }); } -function summariseConfig(c: ReturnType): Record { - return { - nodeEnv: c.nodeEnv, - commitHash: c.commitHash, - logLevel: c.logLevel, - dryRun: c.dryRun, - network: c.network.name, - venue: { kind: c.venue.kind, address: c.venue.address }, - pricing: c.pricing, - sizing: c.sizing, - risk: c.risk, - gas: c.gas, - timing: c.timing, - }; -} - main(); diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 441f061..e78acd1 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -14,6 +14,7 @@ import { HealthCheck } from "../../core/healthcheck.ts"; import { runMakerLoop } from "../../core/runner.ts"; import { serializeError } from "../../core/errSerializer.ts"; import { createPerpsVenue } from "../../adapters/perps/index.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; import { loadPerpsConfig } from "./config.ts"; async function main(): Promise { @@ -121,7 +122,7 @@ async function main(): Promise { const health = new HealthCheck({ port: config.health.port, appName: "perps-mm", - configSummary: summariseConfig(config), + configSummary: sanitiseConfig(config), oracle, inventory, collateral, @@ -148,20 +149,4 @@ async function main(): Promise { }); } -function summariseConfig(c: ReturnType): Record { - return { - nodeEnv: c.nodeEnv, - commitHash: c.commitHash, - logLevel: c.logLevel, - dryRun: c.dryRun, - network: c.network.name, - venue: { kind: c.venue.kind, address: c.venue.address }, - pricing: c.pricing, - sizing: c.sizing, - risk: c.risk, - gas: c.gas, - timing: c.timing, - }; -} - main(); diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index e26087b..6628262 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -4,6 +4,8 @@ import yaml from "js-yaml"; import { type StringOptions, type TUnsafe, type TSchema, Type } from "@sinclair/typebox"; import Ajv from "ajv"; import addFormats from "ajv-formats"; +import type { Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; import { ConfigError } from "../errors.ts"; import { parseUsd, secondsToMs } from "./units.ts"; @@ -402,3 +404,53 @@ export function configBigint(value: string, field: string): bigint { throw new ConfigError(`Invalid bigint value for ${field}: "${value}"`); } } + +/** + * Returns a deep clone of the full parsed config with secrets redacted, safe + * to expose on the /health endpoint. Specifically: + * + * - Each `wallets[name].privateKey` is replaced with "[REDACTED]" and a + * derived `address` is added so operators can still verify which signer + * is configured. + * - `network.rpcUrl` is masked to origin only — paths/query strings on + * managed RPC providers (Alchemy, Infura, …) usually carry API keys. + * + * Bigints in the parsed config (e.g. risk caps, sizing.baseQuantity) are + * preserved as-is; the caller is expected to JSON.stringify with a replacer + * that handles bigints. + */ +export function sanitiseConfig< + T extends { + wallets: Record; + network: { rpcUrl: string }; + }, +>(config: T): Record { + const clone = structuredClone(config) as Record; + + const wallets = clone.wallets as Record; + for (const [name, wallet] of Object.entries(wallets)) { + let address: string; + try { + address = privateKeyToAccount(wallet.privateKey as Hex).address; + } catch { + address = "[invalid]"; + } + wallets[name] = { privateKey: "[REDACTED]", address }; + } + + const network = clone.network as { rpcUrl: string }; + network.rpcUrl = maskRpcUrl(network.rpcUrl); + + return clone; +} + +function maskRpcUrl(raw: string): string { + try { + const url = new URL(raw); + const hasPath = url.pathname && url.pathname !== "/"; + const hasQuery = url.search.length > 0; + return hasPath || hasQuery ? `${url.protocol}//${url.host}/[redacted]` : `${url.protocol}//${url.host}`; + } catch { + return "[invalid url]"; + } +} diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index 6b35fa1..c652f53 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -19,7 +19,12 @@ export interface ExecutorStats { export interface HealthCheckOptions { port: number; appName: string; - /** Sanitised, JSON-safe config slice for /health output. */ + /** + * Full parsed config with secrets redacted (private keys, RPC API keys), + * surfaced verbatim under `config` in /health output. Build via + * `sanitiseConfig` from `core/config/base.ts`. Bigints are serialised to + * strings by the /health JSON.stringify replacer. + */ configSummary: Record; oracle: OracleTracker; inventory: InventoryManager; @@ -97,63 +102,72 @@ export class HealthCheck { private handleHealth(res: ServerResponse): void { const { oracle, inventory, collateral, book, gas, risk } = this.opts; - const body = JSON.stringify({ - app: this.opts.appName, - status: this.status, - walletAddress: this.walletAddress, - lastError: this.lastError, - uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), - config: this.opts.configSummary, - market: { - oraclePrice: oracle.currentPrice.toString(), - volatility: fractionToNumber(oracle.volatility), - bestBid: book.bestBid.toString(), - bestAsk: book.bestAsk.toString(), - ownOrders: book.ownOrders.size, + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + config: this.opts.configSummary, + market: { + oraclePrice: oracle.currentPrice.toString(), + volatility: fractionToNumber(oracle.volatility), + bestBid: book.bestBid.toString(), + bestAsk: book.bestAsk.toString(), + ownOrders: book.ownOrders.size, + }, + inventory: { + netPosition: inventory.netQuantity.toString(), + inventorySkew: fractionToNumber(inventory.inventorySkew), + }, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + venueOrderMargin: collateral.venueOrderMargin.toString(), + venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), + walletTokenBalance: collateral.walletTokenBalance.toString(), + nativeBalance: collateral.nativeBalance.toString(), + utilizationPct: collateral.utilizationPct, + }, + gas: { + gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: gas.isGasSpiking, + gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), + }, + stats: { + tickCount: this.tickCount, + lastTickAt: this.lastTickAt, + ordersPlaced: this.executorStats?.ordersPlaced ?? 0, + ordersCancelled: this.executorStats?.ordersCancelled ?? 0, + reconcileCount: this.executorStats?.reconcileCount ?? 0, + }, }, - inventory: { - netPosition: inventory.netQuantity.toString(), - inventorySkew: fractionToNumber(inventory.inventorySkew), - }, - collateral: { - vaultBalance: collateral.vaultBalance.toString(), - portfolioIM: collateral.portfolioIM.toString(), - portfolioMM: collateral.portfolioMM.toString(), - venueOrderMargin: collateral.venueOrderMargin.toString(), - venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), - walletTokenBalance: collateral.walletTokenBalance.toString(), - nativeBalance: collateral.nativeBalance.toString(), - utilizationPct: collateral.utilizationPct, - }, - gas: { - gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), - gasSpiking: gas.isGasSpiking, - gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), - }, - risk: { - throttled: risk.throttled, - throttleReason: risk.throttleReason, - cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), - }, - stats: { - tickCount: this.tickCount, - lastTickAt: this.lastTickAt, - ordersPlaced: this.executorStats?.ordersPlaced ?? 0, - ordersCancelled: this.executorStats?.ordersCancelled ?? 0, - reconcileCount: this.executorStats?.reconcileCount ?? 0, - }, - }); + bigIntReplacer, + ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(body); } private handleStop(res: ServerResponse): void { - if (this.paused) return this.respondOk(res); + if (this.paused) { + this.respondOk(res); + return; + } this.paused = true; this.status = "stopped"; this.lastError = null; - if (!this.onStop) return this.respondOk(res); + if (!this.onStop) { + this.respondOk(res); + return; + } this.onStop() .then(() => this.respondOk(res)) .catch((err) => { @@ -163,13 +177,24 @@ export class HealthCheck { }); } + private respondOk(res: ServerResponse): void { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, status: this.status })); + } + private handleStart(res: ServerResponse): void { - if (!this.paused) return this.respondOk(res); + if (!this.paused) { + this.respondOk(res); + return; + } this.paused = false; this.status = "running"; this.lastError = null; - if (!this.onStart) return this.respondOk(res); + if (!this.onStart) { + this.respondOk(res); + return; + } this.onStart() .then(() => this.respondOk(res)) .catch((err) => { @@ -178,14 +203,13 @@ export class HealthCheck { res.end(JSON.stringify({ ok: false, error: "start callback failed" })); }); } - - private respondOk(res: ServerResponse): void { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, status: this.status })); - } } function fractionToNumber(value: Fraction): number { // diagnostic only — never used in trading math return (Number(value.s) * Number(value.n)) / Number(value.d); } + +function bigIntReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} From 1306a5503d4275dbdbd9b7a6c02dbcf170616514 Mon Sep 17 00:00:00 2001 From: abs2023 Date: Wed, 6 May 2026 08:38:18 -0500 Subject: [PATCH 014/180] feat: add health URLs to deployment workflow - Introduced perps_health_url and futures_health_url outputs in the deployment workflow for enhanced health check visibility. - Updated additional_info to include health check links in the deployment summary for better monitoring. --- .github/workflows/deploy-col-mar-mm.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index 372222f..a537584 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -75,6 +75,8 @@ jobs: futures_service: ${{ steps.env_config.outputs.futures_service }} perps_task_family: ${{ steps.env_config.outputs.perps_task_family }} futures_task_family: ${{ steps.env_config.outputs.futures_task_family }} + perps_health_url: ${{ steps.env_config.outputs.perps_health_url }} + futures_health_url: ${{ steps.env_config.outputs.futures_health_url }} is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} steps: @@ -100,18 +102,24 @@ jobs: echo "perps_task_family=tsk-col-mar-perps-mm" >> $GITHUB_OUTPUT echo "futures_task_family=tsk-col-mar-futures-mm" >> $GITHUB_OUTPUT + # URL_HOST_PREFIX matches the per-env Route53 alias produced by the + # collateral-margin TF stack (perps_mm_endpoint / futures_mm_endpoint + # in 00_outputs.tf): dev/stg get a subdomain, prod uses the apex. case "$ENV" in dev) SUFFIX="dev" MAKER_ENV="dev" + URL_HOST_PREFIX="dev." ;; stg) SUFFIX="stg" MAKER_ENV="stg" + URL_HOST_PREFIX="stg." ;; main) SUFFIX="lmn" MAKER_ENV="prd" + URL_HOST_PREFIX="" ;; *) echo "::error::Unknown environment '$ENV'" @@ -124,6 +132,8 @@ jobs: echo "ecs_cluster=ecs-derivatives-marketplace-${SUFFIX}" >> $GITHUB_OUTPUT echo "perps_service=svc-col-mar-perps-mm-${SUFFIX}" >> $GITHUB_OUTPUT echo "futures_service=svc-col-mar-futures-mm-${SUFFIX}" >> $GITHUB_OUTPUT + echo "perps_health_url=https://perpsmm.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT + echo "futures_health_url=https://futuresmm.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -467,4 +477,4 @@ jobs: slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} github_token: ${{ secrets.GITHUB_TOKEN }} image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} - additional_info: '${{ needs.build.outputs.is_cicd_branch == ''true'' && ''*Mode:* CI/CD Test (build only, no deploy)'' || format(''*Cluster:* `{0}` • *Services:* `{1}`, `{2}`'', needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service) }}' + additional_info: '${{ needs.build.outputs.is_cicd_branch == ''true'' && ''*Mode:* CI/CD Test (build only, no deploy)'' || format(''*Cluster:* `{0}` • *Services:* `{1}`, `{2}` • *Health:* <{3}|Perps> <{4}|Futures>'', needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service, needs.build.outputs.perps_health_url, needs.build.outputs.futures_health_url) }}' From a9a49207aa16ef03d80f3796008261e82fe93762 Mon Sep 17 00:00:00 2001 From: abs2023 Date: Wed, 6 May 2026 08:52:13 -0500 Subject: [PATCH 015/180] refactor: update additional_info formatting in deployment workflow - Changed the formatting of additional_info in the deployment workflow to improve readability and structure. - Ensured health check URLs are displayed more clearly in the deployment summary. --- .github/workflows/deploy-col-mar-mm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index a537584..1683da4 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -477,4 +477,4 @@ jobs: slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} github_token: ${{ secrets.GITHUB_TOKEN }} image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} - additional_info: '${{ needs.build.outputs.is_cicd_branch == ''true'' && ''*Mode:* CI/CD Test (build only, no deploy)'' || format(''*Cluster:* `{0}` • *Services:* `{1}`, `{2}` • *Health:* <{3}|Perps> <{4}|Futures>'', needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service, needs.build.outputs.perps_health_url, needs.build.outputs.futures_health_url) }}' + additional_info: "${{ needs.build.outputs.is_cicd_branch == 'true' && '*Mode:* CI/CD Test (build only, no deploy)' || format('*Health:* <{0}|Perps> <{1}|Futures>\n*Cluster:* `{2}` • *Services:* `{3}`, `{4}`', needs.build.outputs.perps_health_url, needs.build.outputs.futures_health_url, needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service) }}" From 3facd2f0de76b3f130a73e1953bc344834aea28b Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 20:06:08 +0200 Subject: [PATCH 016/180] feat: hashprice subgraph integration for historical price query for volatility calculation --- market-maker/configs/futures.dev.yml | 19 ++- market-maker/configs/futures.local.yml | 7 + market-maker/configs/futures.prd.yml | 19 ++- market-maker/configs/futures.stg.yml | 19 ++- market-maker/configs/perps.dev.yml | 21 ++- market-maker/configs/perps.local.yml | 7 + market-maker/configs/perps.prd.yml | 19 ++- market-maker/configs/perps.stg.yml | 19 ++- market-maker/schemas/futures.json | 73 ++++++++++ market-maker/schemas/perps.json | 73 ++++++++++ market-maker/src/apps/futures/config.ts | 11 +- market-maker/src/apps/futures/main.ts | 13 +- market-maker/src/apps/perps/config.ts | 11 +- market-maker/src/apps/perps/main.ts | 13 +- market-maker/src/core/config/base.ts | 72 ++++++++++ market-maker/src/core/healthcheck.ts | 42 +++++- .../src/core/historicalPriceSource.ts | 132 ++++++++++++++++++ market-maker/src/core/math.ts | 83 +++++++++-- market-maker/src/core/oracleTracker.ts | 131 ++++++++++++++++- .../src/core/pricing/effectiveSpread.ts | 45 ++++-- .../src/core/pricing/reservationPrice.ts | 69 +++++---- market-maker/src/core/quoter.ts | 9 ++ market-maker/src/core/runner.ts | 4 +- .../tests/apps/futures/config.test.ts | 1 + .../tests/apps/futures/main.smoke.test.ts | 1 + market-maker/tests/apps/perps/config.test.ts | 1 + .../tests/apps/perps/main.smoke.test.ts | 1 + market-maker/tests/core/math.test.ts | 70 ++++++++++ market-maker/tests/core/oracleTracker.test.ts | 113 +++++++++++++-- .../core/pricing/reservationPrice.test.ts | 13 +- 30 files changed, 1010 insertions(+), 101 deletions(-) create mode 100644 market-maker/src/core/historicalPriceSource.ts diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 552b2e0..936c043 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -1,10 +1,11 @@ # yaml-language-server: $schema=../schemas/futures.json # Titan Market Maker - Futures - DEV (base-sepolia). # -# PRIVATE_KEY - hex private key of the dev market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-sepolia -# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia +# PRIVATE_KEY - hex private key of the dev market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# FUTURES_ADDRESS - Futures address on base-sepolia +# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia +# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill nodeEnv: development commitHash: ${COMMIT_HASH:-unknown} @@ -74,5 +75,15 @@ collateral: autoDepositMinAmount: 500 maxCollateralAmount: 1000 +oracle: + # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless + # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat + # so backfill returns a full window. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 41f864d..84f4095 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -65,5 +65,12 @@ collateral: autoDeposit: false autoDepositMinAmount: 0 +oracle: + # No `history:` block → cold start, σ warms up live as the local oracle + # ticks (likely never on hardhat unless you script price updates). + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + health: port: 3001 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index 7ab33a2..c38d61f 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -1,10 +1,11 @@ # yaml-language-server: $schema=../schemas/futures.json # Titan Market Maker - Futures - PRODUCTION (base-mainnet). # -# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup nodeEnv: production commitHash: ${COMMIT_HASH:-unknown} @@ -67,5 +68,15 @@ collateral: autoDeposit: true autoDepositMinAmount: 1 +oracle: + # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat + # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours + # of oracle activity, easily enough to fill the window on cold start. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 91a9b65..0f9e47c 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -1,10 +1,11 @@ # yaml-language-server: $schema=../schemas/futures.json # Titan Market Maker - Futures - STAGING (base-mainnet). # -# PRIVATE_KEY - hex private key of the staging market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# PRIVATE_KEY - hex private key of the staging market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup nodeEnv: staging commitHash: ${COMMIT_HASH:-unknown} @@ -67,5 +68,15 @@ collateral: autoDeposit: true autoDepositMinAmount: 1 +oracle: + # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat + # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours + # of oracle activity, easily enough to fill the window on cold start. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 725363c..12ccdc4 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -4,10 +4,11 @@ # Real quoting on base-sepolia. Small sizes, debug-level logs, autoDeposit # on so the wallet stays funded. # -# PRIVATE_KEY - hex private key of the dev market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia -# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia +# PRIVATE_KEY - hex private key of the dev market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia +# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia +# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill nodeEnv: development commitHash: ${COMMIT_HASH:-unknown} @@ -15,7 +16,7 @@ logLevel: ${MAKER_LOG_LEVEL:-debug} dryRun: ${MAKER_DRY_RUN:-false} # Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we # don't burn gas on cancel-then-reopen across every restart. -cancelOrdersOnShutdown: false +cancelOrdersOnShutdown: true wallets: primary: @@ -77,5 +78,15 @@ collateral: autoDepositMinAmount: 500 maxCollateralAmount: 1000 +oracle: + # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless + # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat + # so backfill returns a full window. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} + health: port: ${MAKER_HEALTH_PORT:-3002} diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index a7ead6a..a1ae1df 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -71,5 +71,12 @@ collateral: autoDeposit: false autoDepositMinAmount: 0 +oracle: + # No `history:` block → cold start, σ warms up live as the local oracle + # ticks (likely never on hardhat unless you script price updates). + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index 2962a9f..f219beb 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -5,10 +5,11 @@ # info to keep CloudWatch ingestion costs bounded. Tune sizing per # liquidity provision target. # -# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet (USD-denominated risk gates) +# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet (USD-denominated risk gates) +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup nodeEnv: production commitHash: ${COMMIT_HASH:-unknown} @@ -69,5 +70,15 @@ collateral: autoDeposit: true autoDepositMinAmount: 1 +oracle: + # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat + # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours + # of oracle activity, easily enough to fill the window on cold start. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index f9e2361..f658acc 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -4,10 +4,11 @@ # Real money on base-mainnet, but pre-prod sizes / risk caps. Debug-level # logs to make incident triage easier in shared infra. # -# PRIVATE_KEY - hex private key of the staging market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# PRIVATE_KEY - hex private key of the staging market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup nodeEnv: staging commitHash: ${COMMIT_HASH:-unknown} @@ -68,5 +69,15 @@ collateral: autoDeposit: true autoDepositMinAmount: 1 +oracle: + # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat + # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours + # of oracle activity, easily enough to fill the window on cold start. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + health: port: ${MAKER_HEALTH_PORT:-3001} diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 9e1842e..f7ee9eb 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -18,6 +18,7 @@ "risk", "gas", "collateral", + "oracle", "timing", "health" ], @@ -668,6 +669,78 @@ } } }, + "oracle": { + "additionalProperties": false, + "description": "OracleTracker / volatility-window configuration.", + "type": "object", + "required": [ + "windowSize", + "precisionBits", + "historyLookbackMultiplier" + ], + "properties": { + "windowSize": { + "anyOf": [ + { + "minimum": 3, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "default": 60 + }, + "precisionBits": { + "anyOf": [ + { + "minimum": 16, + "maximum": 256, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", + "default": 48 + }, + "historyLookbackMultiplier": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", + "default": 4 + }, + "history": { + "additionalProperties": false, + "description": "Historical price source for σ window backfill.", + "type": "object", + "required": [ + "subgraphUrl" + ], + "properties": { + "subgraphUrl": { + "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", + "type": "string" + } + } + } + } + }, "timing": { "additionalProperties": false, "description": "Loop cadences and requote thresholds.", diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 3f7f878..78e8f4d 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -18,6 +18,7 @@ "risk", "gas", "collateral", + "oracle", "timing", "health" ], @@ -645,6 +646,78 @@ } } }, + "oracle": { + "additionalProperties": false, + "description": "OracleTracker / volatility-window configuration.", + "type": "object", + "required": [ + "windowSize", + "precisionBits", + "historyLookbackMultiplier" + ], + "properties": { + "windowSize": { + "anyOf": [ + { + "minimum": 3, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "default": 60 + }, + "precisionBits": { + "anyOf": [ + { + "minimum": 16, + "maximum": 256, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", + "default": 48 + }, + "historyLookbackMultiplier": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", + "default": 4 + }, + "history": { + "additionalProperties": false, + "description": "Historical price source for σ window backfill.", + "type": "object", + "required": [ + "subgraphUrl" + ], + "properties": { + "subgraphUrl": { + "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", + "type": "string" + } + } + } + } + }, "timing": { "additionalProperties": false, "description": "Loop cadences and requote thresholds.", diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 82e5c3e..b3bbcad 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -1,6 +1,7 @@ import { type Static, Type } from "@sinclair/typebox"; import { type ParsedCollateralConfig, + type ParsedOracleConfig, type ParsedRiskConfig, type ParsedTimingConfig, TypeEthAddress, @@ -10,7 +11,9 @@ import { healthSchema, loadConfigFromFile, networkSchema, + oracleSchema, parseCollateralConfig, + parseOracleConfig, parseRiskConfig, parseTimingConfig, riskSchema, @@ -139,6 +142,7 @@ export const futuresRootSchema = Type.Object( risk: riskSchema, gas: gasSchema, collateral: collateralSchema, + oracle: oracleSchema, timing: timingSchema, health: healthSchema, }, @@ -148,10 +152,14 @@ export const futuresRootSchema = Type.Object( type RawFuturesConfig = Static; /** Parsed futures config: bigints/ms substituted in for human-friendly inputs. */ -export type FuturesMakerConfig = Omit & { +export type FuturesMakerConfig = Omit< + RawFuturesConfig, + "risk" | "timing" | "collateral" | "sizing" | "oracle" +> & { risk: ParsedRiskConfig; timing: ParsedTimingConfig; collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; sizing: Omit & { baseQuantity: bigint }; }; @@ -165,6 +173,7 @@ export function loadFuturesConfig(opts: { path?: string; env?: NodeJS.ProcessEnv risk: parseRiskConfig(raw.risk), timing: parseTimingConfig(raw.timing), collateral: parseCollateralConfig(raw.collateral), + oracle: parseOracleConfig(raw.oracle), sizing: { ...raw.sizing, baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index d204d37..0b0d252 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -3,6 +3,7 @@ import { loadDotenvFiles } from "../../core/env.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; +import { HashpriceOracleSubgraphSource } from "../../core/historicalPriceSource.ts"; import { GasTracker } from "../../core/gasTracker.ts"; import { InventoryManager } from "../../core/inventoryManager.ts"; import { CollateralTracker } from "../../core/collateralTracker.ts"; @@ -40,7 +41,16 @@ async function main(): Promise { // is stateless and skips this step. await instrument.ownOrders.bootstrap(); - const oracle = new OracleTracker(instrument, logger); + const history = config.oracle.history + ? new HashpriceOracleSubgraphSource({ url: config.oracle.history.subgraphUrl, logger }) + : undefined; + const oracle = new OracleTracker(instrument, logger, { + windowSize: config.oracle.windowSize, + precisionBits: config.oracle.precisionBits, + historyLookbackMultiplier: config.oracle.historyLookbackMultiplier, + history, + pollIntervalMs: config.timing.pollIntervalMs, + }); const gas = new GasTracker( network.publicClient, { @@ -104,6 +114,7 @@ async function main(): Promise { }, maxSkewTicks: config.pricing.maxSkewTicks, levelSpacingTicks: config.timing.levelSpacingTicks, + volHorizonSec: config.timing.pollIntervalMs / 1000, }, oracle, gas, diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 3320926..a94e7fd 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -1,6 +1,7 @@ import { type Static, Type } from "@sinclair/typebox"; import { type ParsedCollateralConfig, + type ParsedOracleConfig, type ParsedRiskConfig, type ParsedTimingConfig, TypeEthAddress, @@ -10,7 +11,9 @@ import { healthSchema, loadConfigFromFile, networkSchema, + oracleSchema, parseCollateralConfig, + parseOracleConfig, parseRiskConfig, parseTimingConfig, riskSchema, @@ -125,6 +128,7 @@ export const perpsRootSchema = Type.Object( risk: riskSchema, gas: gasSchema, collateral: collateralSchema, + oracle: oracleSchema, timing: timingSchema, health: healthSchema, }, @@ -134,10 +138,14 @@ export const perpsRootSchema = Type.Object( type RawPerpsConfig = Static; /** Parsed perps config: bigints/ms substituted in for human-friendly inputs. */ -export type PerpsMakerConfig = Omit & { +export type PerpsMakerConfig = Omit< + RawPerpsConfig, + "risk" | "timing" | "collateral" | "sizing" | "oracle" +> & { risk: ParsedRiskConfig; timing: ParsedTimingConfig; collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; sizing: Omit & { baseQuantity: bigint }; }; @@ -151,6 +159,7 @@ export function loadPerpsConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } risk: parseRiskConfig(raw.risk), timing: parseTimingConfig(raw.timing), collateral: parseCollateralConfig(raw.collateral), + oracle: parseOracleConfig(raw.oracle), sizing: { ...raw.sizing, baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index e78acd1..e39bd24 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -3,6 +3,7 @@ import { loadDotenvFiles } from "../../core/env.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; +import { HashpriceOracleSubgraphSource } from "../../core/historicalPriceSource.ts"; import { GasTracker } from "../../core/gasTracker.ts"; import { InventoryManager } from "../../core/inventoryManager.ts"; import { CollateralTracker } from "../../core/collateralTracker.ts"; @@ -35,7 +36,16 @@ async function main(): Promise { }); const instrument = await venue.getInstrument(); - const oracle = new OracleTracker(instrument, logger); + const history = config.oracle.history + ? new HashpriceOracleSubgraphSource({ url: config.oracle.history.subgraphUrl, logger }) + : undefined; + const oracle = new OracleTracker(instrument, logger, { + windowSize: config.oracle.windowSize, + precisionBits: config.oracle.precisionBits, + historyLookbackMultiplier: config.oracle.historyLookbackMultiplier, + history, + pollIntervalMs: config.timing.pollIntervalMs, + }); const gas = new GasTracker( network.publicClient, { @@ -97,6 +107,7 @@ async function main(): Promise { }, maxSkewTicks: config.pricing.maxSkewTicks, levelSpacingTicks: config.timing.levelSpacingTicks, + volHorizonSec: config.timing.pollIntervalMs / 1000, }, oracle, gas, diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index 6628262..abf2492 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -206,6 +206,51 @@ export const healthSchema = Type.Object( { ...Closed, description: "Health-check HTTP server." }, ); +/** + * Oracle / volatility-window configuration. Shared between perps and futures + * because both consume the same underlying Hashprice USD aggregator and want + * the same per-second volatility math. + * + * `history.subgraphUrl` enables startup backfill from the hashprice-oracle + * subgraph (`HashpriceUsd` time series). Without it, the rolling window + * starts empty and σ warms up live as the on-chain feed updates. + */ +export const oracleSchema = Type.Object( + { + windowSize: Type.Integer({ + minimum: 3, + default: 60, + description: + "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + }), + precisionBits: Type.Integer({ + minimum: 16, + maximum: 256, + default: 48, + description: + "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", + }), + historyLookbackMultiplier: Type.Number({ + minimum: 1, + default: 4, + description: + "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", + }), + history: Type.Optional( + Type.Object( + { + subgraphUrl: Type.String({ + description: + "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use $\u007BVAR:-\u007D patterns; omit the entire `history` block for the same effect.", + }), + }, + { ...Closed, description: "Historical price source for σ window backfill." }, + ), + ), + }, + { ...Closed, description: "OracleTracker / volatility-window configuration." }, +); + /** * ${VAR} expansion. Recursively walks strings in the parsed YAML and replaces * ${NAME} with process.env.NAME. The `${NAME:-default}` form supplies a @@ -268,6 +313,14 @@ export interface ParsedCollateralConfig { maxCollateralAmount?: bigint; } +export interface ParsedOracleConfig { + windowSize: number; + precisionBits: number; + historyLookbackMultiplier: number; + /** Undefined when `history` is omitted; backfill is then skipped. */ + history?: { subgraphUrl: string }; +} + interface RawRisk { maxPositionSize: string | number; maxUtilizationPct: number; @@ -291,6 +344,12 @@ interface RawCollateral { autoDepositMinAmount: string | number; maxCollateralAmount?: string | number; } +interface RawOracle { + windowSize: number; + precisionBits: number; + historyLookbackMultiplier: number; + history?: { subgraphUrl: string }; +} export function parseRiskConfig(raw: RawRisk): ParsedRiskConfig { return { @@ -339,6 +398,19 @@ export function parseCollateralConfig(raw: RawCollateral): ParsedCollateralConfi }; } +export function parseOracleConfig(raw: RawOracle): ParsedOracleConfig { + // An empty `subgraphUrl` (typical when env var is unset and the YAML uses + // `${VAR:-}`) is treated identically to omitting the `history` block — + // backfill is silently skipped and σ warms up live. + const url = raw.history?.subgraphUrl?.trim(); + return { + windowSize: raw.windowSize, + precisionBits: raw.precisionBits, + historyLookbackMultiplier: raw.historyLookbackMultiplier, + history: url ? { subgraphUrl: url } : undefined, + }; +} + export interface LoadConfigOpts { schema: TSchema; path?: string; diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index c652f53..4cecbd4 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -9,6 +9,7 @@ import type { BookTracker } from "./bookTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; import type { RiskManager } from "./riskManager.ts"; import type { ErrorInfo } from "./errors.ts"; +import type { OwnOrder } from "./adapter.ts"; export interface ExecutorStats { ordersPlaced: number; @@ -112,10 +113,10 @@ export class HealthCheck { config: this.opts.configSummary, market: { oraclePrice: oracle.currentPrice.toString(), - volatility: fractionToNumber(oracle.volatility), + volatilityPerSecond: fractionToNumber(oracle.volatilityPerSecond), bestBid: book.bestBid.toString(), bestAsk: book.bestAsk.toString(), - ownOrders: book.ownOrders.size, + ownOrders: serializeOwnOrders(book.ownOrders), }, inventory: { netPosition: inventory.netQuantity.toString(), @@ -206,10 +207,43 @@ export class HealthCheck { } function fractionToNumber(value: Fraction): number { - // diagnostic only — never used in trading math - return (Number(value.s) * Number(value.n)) / Number(value.d); + // diagnostic only — never used in trading math. + // Realized-vol Fractions can have 1000+ bit numerators/denominators (sqrt at + // 48-bit precision over a 60-sample window), so a naive Number cast overflows + // both sides to Infinity and JSON-serialises as `null`. Simplify first to + // collapse the magnitude before the cast. + const v = value.simplify(1e-12); + return (Number(v.s) * Number(v.n)) / Number(v.d); } function bigIntReplacer(_key: string, value: unknown): unknown { return typeof value === "bigint" ? value.toString() : value; } + +interface OwnOrdersView { + count: number; + bids: Array<{ orderId: `0x${string}`; price: bigint; size: bigint }>; + asks: Array<{ orderId: `0x${string}`; price: bigint; size: bigint }>; +} + +/** + * Snapshot of resting MM orders, split by side and sorted top-of-book first + * (best bid = highest price, best ask = lowest price). Bigints are stringified + * by `bigIntReplacer` when the payload is serialised. + */ +function serializeOwnOrders(orders: ReadonlyMap<`0x${string}`, OwnOrder>): OwnOrdersView { + const bids: OwnOrder[] = []; + const asks: OwnOrder[] = []; + for (const order of orders.values()) { + (order.side === "buy" ? bids : asks).push(order); + } + // bigint compare; Number(a-b) would lose precision on large prices. + bids.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); + asks.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); + const project = (o: OwnOrder) => ({ orderId: o.orderId, price: o.price, size: o.size }); + return { + count: orders.size, + bids: bids.map(project), + asks: asks.map(project), + }; +} diff --git a/market-maker/src/core/historicalPriceSource.ts b/market-maker/src/core/historicalPriceSource.ts new file mode 100644 index 0000000..cf92e51 --- /dev/null +++ b/market-maker/src/core/historicalPriceSource.ts @@ -0,0 +1,132 @@ +/** + * # Historical price source + * + * Backfills the OracleTracker's rolling window at startup so realized + * volatility is meaningful from the first quote, instead of waiting + * `windowSize × pollInterval` for the window to populate from live polls. + * + * Both perps and futures consume the Hashprice USD aggregator on chain. The + * `hashprice-oracle` subgraph indexes every aggregator update as a + * `HashpriceUsd` time-series entity, so a single shared source serves both + * apps. + * + * Log returns `ln(p_i / p_{i-1})` are scale-invariant, so we deliberately + * skip rebasing subgraph prices to token decimals — the rolling window only + * needs the *ratios*, and avoiding the rebase keeps this module independent + * of the venue adapters. + */ + +import type pino from "pino"; + +export interface PricePoint { + /** Unix timestamp in seconds. */ + timestampSec: number; + /** Raw price as stored by the source (units irrelevant — log returns are scale-free). */ + price: bigint; +} + +export interface HistoricalPriceSource { + /** + * Returns up to `maxPoints` price samples within the last `lookbackSec` + * seconds, oldest first. Implementations should silently truncate if the + * source has fewer matching points; callers tolerate short results. + */ + fetch(opts: { lookbackSec: number; maxPoints: number }): Promise; +} + +/** + * Hashprice-oracle subgraph implementation. + * + * Queries the `HashpriceUsd` time-series entity, which is written every time + * either the BTC/USD Chainlink feed or the on-chain hashprice contract emits + * a fresh answer (see `indexer/src/hashprice.ts → deriveHashpriceUsd`). + * + * The Graph's GraphQL API speaks plain JSON over HTTP; we use Node's global + * `fetch` (>=22) so the MM keeps a single runtime dependency surface. + */ +export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { + private readonly url: string; + private readonly logger: pino.Logger; + private readonly fetchImpl: typeof fetch; + + constructor(opts: { url: string; logger: pino.Logger; fetchImpl?: typeof fetch }) { + this.url = opts.url; + this.logger = opts.logger.child({ component: "hashprice-subgraph" }); + this.fetchImpl = opts.fetchImpl ?? fetch; + } + + async fetch(opts: { lookbackSec: number; maxPoints: number }): Promise { + const sinceSec = Math.floor(Date.now() / 1000) - Math.max(0, Math.floor(opts.lookbackSec)); + // The Graph's `Timestamp` scalar is **microseconds since Unix epoch**, not + // seconds. Both the `where: { timestamp_gte: ... }` filter and the returned + // field use µs. We rescale at the boundary so the rest of the codebase + // stays in seconds. + const sinceMicros = BigInt(sinceSec) * 1_000_000n; + // The Graph hosted-service caps `first` at 1000 per query; clamp so a + // misconfigured `windowSize × multiplier` doesn't get rejected at the + // gateway. + const first = Math.min(Math.max(1, Math.floor(opts.maxPoints)), 1000); + // Newest-first lets us hit small `first` values without paginating; we + // reverse below to return oldest-first as the OracleTracker expects. + // + // `Timestamp` (i64) variables must be sent as JSON **strings** — passing a + // number gets rejected with `Invalid value provided for argument "since": + // Int(Number(...))`. Verified against the goldsky gateway with + // introspection + a probe. + const query = ` + query HashpriceHistory($since: Timestamp!, $first: Int!) { + hashpriceUsds( + where: { timestamp_gte: $since } + orderBy: timestamp + orderDirection: desc + first: $first + ) { + timestamp + price + } + } + `; + const body = JSON.stringify({ + query, + variables: { since: sinceMicros.toString(), first }, + }); + + const res = await this.fetchImpl(this.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + if (!res.ok) { + throw new Error( + `hashprice-subgraph: ${res.status} ${res.statusText} from ${this.url}`, + ); + } + const json = (await res.json()) as { + data?: { hashpriceUsds?: Array<{ timestamp: string | number; price: string }> }; + errors?: Array<{ message: string }>; + }; + if (json.errors && json.errors.length > 0) { + throw new Error( + `hashprice-subgraph: GraphQL errors: ${json.errors.map((e) => e.message).join("; ")}`, + ); + } + + const rows = json.data?.hashpriceUsds ?? []; + const points: PricePoint[] = rows.map((r) => ({ + // Timestamp is microseconds (see the `since` rescale above); convert + // back to seconds for downstream math. Number() is safe here: i64 µs + // up to year 2262 stays well within Number.MAX_SAFE_INTEGER once + // divided by 1e6. + timestampSec: Number(BigInt(r.timestamp) / 1_000_000n), + price: BigInt(r.price), + })); + // Subgraph returned newest-first; flip so callers can push in chronological order. + points.reverse(); + + this.logger.debug( + { url: this.url, requested: first, got: points.length, sinceSec }, + "fetched hashprice history", + ); + return points; + } +} diff --git a/market-maker/src/core/math.ts b/market-maker/src/core/math.ts index 21c115c..bf94ade 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -51,16 +51,30 @@ export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); /** * Rolling window of bigint samples. Computes: - * - realized volatility = stddev of log returns (Fraction-precise) + * - per-step realized volatility = stddev of log returns (Fraction-precise) + * - per-second realized volatility = stddev of time-normalised log returns + * (requires timestamps on every push) * - median (bigint) * - * # Volatility math + * # Per-step volatility math * * r_i = ln(p_i / p_{i-1}) (log return per step) * μ = (Σ r_i) / N * σ² = (Σ (r_i − μ)²) / (N − 1) * σ = sqrt(σ²) ← returned as Fraction * + * # Per-second volatility math + * + * Each step covers a possibly-variable Δt_i seconds. For a Brownian process + * with per-second stddev σ_s, Var(r_i) = σ_s² · Δt_i, so the time-normalised + * return x_i = r_i / √Δt_i has constant variance σ_s². Then σ_s is the sample + * stddev of {x_i}: + * + * Δt_i = t_i − t_{i-1} + * x_i = r_i / √Δt_i + * σ_s² = Σ (x_i − μ)² / (N − 1) + * σ_s = sqrt(σ_s²) (units: dimensionless × s^-1/2) + * * Notes: * - We compute log returns as `ln(curr/prev)`, NOT `ln(curr) − ln(prev)` as * two separate logs — Fraction.div is exact, and one ln call is half the @@ -70,9 +84,14 @@ export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); * - `precisionBits` controls the bigint-only `ln`/`sqrt` approximations * (see rational.ts). 48 bits is plenty for vol estimation; tune via * constructor only when a strategy demonstrably needs more. + * - Timestamps are stored alongside samples; passing `undefined` records a + * sentinel and excludes that pair from `volatilityPerSecond` (gas tracker + * pushes without timestamps and only consumes `median`, so this stays + * backwards-compatible). */ export class RollingWindow { private readonly samples: bigint[] = []; + private readonly timestampsSec: number[] = []; private readonly maxSize: number; private readonly precisionBits: number; @@ -81,10 +100,16 @@ export class RollingWindow { this.precisionBits = precisionBits; } - push(value: bigint): void { + /** + * Append a sample. `timestampSec` is required for `volatilityPerSecond` + * but optional for the per-step `volatility` and `median` consumers. + */ + push(value: bigint, timestampSec?: number): void { this.samples.push(value); + this.timestampsSec.push(timestampSec ?? Number.NaN); if (this.samples.length > this.maxSize) { this.samples.shift(); + this.timestampsSec.shift(); } } @@ -96,7 +121,7 @@ export class RollingWindow { return this.samples.length > 0 ? this.samples[this.samples.length - 1] : undefined; } - /** Realized volatility as stddev of log returns. 0 if fewer than 3 samples. */ + /** Realized per-step volatility (stddev of log returns). 0 if fewer than 3 samples. */ volatility(): Fraction { if (this.samples.length < 3) return new Fraction(0n); @@ -111,18 +136,36 @@ export class RollingWindow { } if (returns.length < 2) return new Fraction(0n); + return sampleStddev(returns, this.precisionBits); + } - let sum = new Fraction(0n); - for (const r of returns) sum = sum.add(r); - const mean = sum.div(new Fraction(BigInt(returns.length))); + /** + * Realized per-second volatility (stddev of √Δt-normalised log returns). + * 0 if fewer than 3 samples or if any required timestamp is missing / + * non-monotonic. Units: dimensionless × s^-1/2. + */ + volatilityPerSecond(): Fraction { + if (this.samples.length < 3) return new Fraction(0n); - let varSum = new Fraction(0n); - for (const r of returns) { - const d = r.sub(mean); - varSum = varSum.add(d.mul(d)); + const xs: Fraction[] = []; + for (let i = 1; i < this.samples.length; i++) { + const prev = this.samples[i - 1]; + const curr = this.samples[i]; + if (prev <= 0n || curr <= 0n) continue; + + const dtSec = this.timestampsSec[i] - this.timestampsSec[i - 1]; + if (!Number.isFinite(dtSec) || dtSec <= 0) continue; + + const r = ln(new Fraction(curr, prev), this.precisionBits); + // Encode Δt as a Fraction with millisecond resolution; sub-ms precision + // is irrelevant given the ≤2^-precisionBits truncation in `sqrt`. + const dt = new Fraction(BigInt(Math.round(dtSec * 1000)), 1000n); + const x = r.div(sqrt(dt, this.precisionBits)); + xs.push(x); } - const variance = varSum.div(new Fraction(BigInt(returns.length - 1))); - return sqrt(variance, this.precisionBits); + + if (xs.length < 2) return new Fraction(0n); + return sampleStddev(xs, this.precisionBits); } /** Median of samples (bigint). */ @@ -135,6 +178,20 @@ export class RollingWindow { } } +function sampleStddev(values: Fraction[], precisionBits: number): Fraction { + let sum = new Fraction(0n); + for (const v of values) sum = sum.add(v); + const mean = sum.div(new Fraction(BigInt(values.length))); + + let varSum = new Fraction(0n); + for (const v of values) { + const d = v.sub(mean); + varSum = varSum.add(d.mul(d)); + } + const variance = varSum.div(new Fraction(BigInt(values.length - 1))); + return sqrt(variance, precisionBits); +} + /** * Rolling budget tracker: sums amounts in a sliding time window. * Used for gas budget enforcement (hourly / daily). diff --git a/market-maker/src/core/oracleTracker.ts b/market-maker/src/core/oracleTracker.ts index 941426f..aa7d995 100644 --- a/market-maker/src/core/oracleTracker.ts +++ b/market-maker/src/core/oracleTracker.ts @@ -1,37 +1,154 @@ import type pino from "pino"; import Fraction from "fraction.js"; import type { InstrumentAdapter } from "./adapter.ts"; +import type { HistoricalPriceSource } from "./historicalPriceSource.ts"; import { RollingWindow } from "./math.ts"; export interface OracleTrackerConfig { + /** Maximum number of samples kept in the rolling window. Defaults to 60. */ windowSize?: number; + /** Bigint precision for `ln` / `sqrt` approximations. Defaults to 48 bits. */ precisionBits?: number; + /** + * Optional historical-price source consulted at `initialize()` time to + * pre-populate the rolling window. Without it the window starts empty and + * volatility is biased to 0 for ~`windowSize × pollInterval` seconds. + */ + history?: HistoricalPriceSource; + /** + * Live poll cadence in milliseconds. Used together with `windowSize` to + * size the historical lookback (`windowSize × pollIntervalMs`). Required + * when `history` is provided; otherwise ignored. + */ + pollIntervalMs?: number; + /** + * Multiplier applied to the lookback window when fetching history. The + * underlying oracle (Chainlink) only updates on deviation/heartbeat, so + * `windowSize × pollIntervalMs` of wall-clock typically yields fewer than + * `windowSize` samples. Querying a wider window and trimming gets us a + * full window. Defaults to 4× — generous enough for slow feeds, small + * enough to keep the gateway response under a few hundred kB. + */ + historyLookbackMultiplier?: number; + /** + * Test seam for deterministic per-second σ math. Returns the current time + * in seconds (with sub-second precision is fine). Defaults to + * `() => Date.now() / 1000`. + */ + nowSec?: () => number; } +/** + * Tracks the latest oracle price and computes realized per-second volatility + * from a rolling window of de-duplicated samples. + * + * # Why de-dupe + * + * The price source is a Chainlink aggregator that only updates on + * deviation/heartbeat (every few minutes for slow feeds like hashprice). + * Polling every few seconds means most polls observe the *same* answer and + * contribute a zero log-return that biases σ toward 0. We push to the window + * only when the answer actually changes; the per-second normalisation in + * `RollingWindow.volatilityPerSecond` then handles the variable Δt between + * consecutive updates. + * + * # Why backfill + * + * Cold starts otherwise need ~`windowSize × medianUpdateInterval` of + * wall-clock before σ is meaningful. With the subgraph-backed + * `HistoricalPriceSource`, the window is already populated when the first + * live tick lands. + */ export class OracleTracker { currentPrice = 0n; - /** Realized volatility as a Fraction (stddev of log returns). */ - volatility: Fraction = new Fraction(0n); + /** + * Realized per-second volatility (Fraction). Units: dimensionless × s^-1/2. + * Pricing strategies multiply by √(holding-time-seconds) to convert into + * a per-step number that can be turned into bps. + */ + volatilityPerSecond: Fraction = new Fraction(0n); private readonly instrument: InstrumentAdapter; private readonly priceWindow: RollingWindow; + private readonly history: HistoricalPriceSource | undefined; + private readonly pollIntervalMs: number | undefined; + private readonly windowSize: number; + private readonly historyLookbackMultiplier: number; + private readonly nowSec: () => number; private readonly logger: pino.Logger; + private lastSampledPrice: bigint | null = null; constructor(instrument: InstrumentAdapter, logger: pino.Logger, cfg: OracleTrackerConfig = {}) { this.instrument = instrument; - this.priceWindow = new RollingWindow(cfg.windowSize ?? 60, cfg.precisionBits ?? 48); + this.windowSize = cfg.windowSize ?? 60; + this.priceWindow = new RollingWindow(this.windowSize, cfg.precisionBits ?? 48); + this.history = cfg.history; + this.pollIntervalMs = cfg.pollIntervalMs; + this.historyLookbackMultiplier = cfg.historyLookbackMultiplier ?? 4; + this.nowSec = cfg.nowSec ?? (() => Date.now() / 1000); this.logger = logger.child({ component: "oracle" }); } + /** + * Backfill the rolling window from `history` (if provided) and read the + * first live price. Safe to call multiple times — successive invocations + * are equivalent to plain `update()`. + */ + async initialize(): Promise { + if (this.history && this.pollIntervalMs && this.pollIntervalMs > 0) { + const lookbackSec = (this.windowSize * this.pollIntervalMs * this.historyLookbackMultiplier) / 1000; + try { + const samples = await this.history.fetch({ + lookbackSec, + maxPoints: this.windowSize * this.historyLookbackMultiplier, + }); + let pushed = 0; + for (const s of samples) { + if (s.price <= 0n) continue; + if (this.lastSampledPrice !== null && s.price === this.lastSampledPrice) continue; + this.priceWindow.push(s.price, s.timestampSec); + this.lastSampledPrice = s.price; + pushed++; + } + if (pushed > 0) { + // Compute σ now so it's already meaningful before the first live tick; + // `update()` only recomputes when a *new* price arrives, and the live + // poll often duplicates the last backfilled sample. + this.volatilityPerSecond = this.priceWindow.volatilityPerSecond(); + } + this.logger.info( + { fetched: samples.length, pushed, windowSize: this.windowSize, lookbackSec }, + "backfilled volatility window from historical source", + ); + } catch (err) { + this.logger.warn( + { err, windowSize: this.windowSize, lookbackSec }, + "history backfill failed; volatility will warm up from live polls", + ); + } + } else if (this.history) { + this.logger.warn( + "history provided without pollIntervalMs; skipping backfill", + ); + } + + await this.update(); + } + async update(): Promise { const price = await this.instrument.getIndexPrice(); this.currentPrice = price; - if (price > 0n) { - this.priceWindow.push(price); - this.volatility = this.priceWindow.volatility(); + if (price > 0n && price !== this.lastSampledPrice) { + this.priceWindow.push(price, this.nowSec()); + this.lastSampledPrice = price; + this.volatilityPerSecond = this.priceWindow.volatilityPerSecond(); } this.logger.debug( - { price: price.toString(), volatility: this.volatility.valueOf() }, + { + price: price.toString(), + volatilityPerSec: this.volatilityPerSecond.valueOf(), + windowFill: this.priceWindow.length, + }, "oracle tick", ); } diff --git a/market-maker/src/core/pricing/effectiveSpread.ts b/market-maker/src/core/pricing/effectiveSpread.ts index 3a9e28d..d31ffaf 100644 --- a/market-maker/src/core/pricing/effectiveSpread.ts +++ b/market-maker/src/core/pricing/effectiveSpread.ts @@ -11,7 +11,7 @@ * half_spread_bps = 0.5 * full_spread_bps * * full_spread_bps = max(min_spread, gas_floor) - * + vol_mult * σ * 1e4 + * + vol_mult * σ_s * √H_sec * 1e4 * + γ * |skew| * min_spread * + gas_penalty * spike_pct / 100 * @@ -24,16 +24,21 @@ * ## Units * * - oracle, bid_mid, ask_mid : token-decimals (USDC base units) - * - σ : per-poll log-return stddev (Fraction, dimensionless) + * - σ_s : per-second log-return stddev (Fraction, units s^-1/2) + * - H_sec : holding-time horizon in seconds (poll interval) * - skew : netQty / maxPos in [-1, 1] (Fraction) * - bps : basis points (1 bp = 0.01%) * + * `σ_s · √H_sec` is the equivalent log-return stddev over an H_sec window; for + * Brownian motion that is what the spread must compensate for between requotes. + * * ## Worked example * - * oracle = 100_000_000 (≈ $100), σ = 0.001 per poll, vol_mult = 2, γ = 0.5, - * skew = +0.4, max_skew_ticks = 20, tick = 1000, min_spread = 10 bps, no gas. + * oracle = 100_000_000 (≈ $100), σ_s = 5.8e-4 per √s (≈ 1e-3 per √3s), + * H = 3 s, vol_mult = 2, γ = 0.5, skew = +0.4, max_skew_ticks = 20, + * tick = 1000, min_spread = 10 bps, no gas. * - * vol_bps = 0.001 * 2 * 1e4 = 20 bps + * vol_bps = 5.8e-4 * √3 * 2 * 1e4 ≈ 20 bps * skew_inv_bps = 0.5 * 0.4 * 10 = 2 bps * full_spread_bps= max(10, 0) + 20 + 2 = 32 bps * half_spread_bps= 16 @@ -54,7 +59,10 @@ import type { OracleTracker } from "../oracleTracker.ts"; import type { GasTracker } from "../gasTracker.ts"; import type { InventoryManager } from "../inventoryManager.ts"; import { BPS_SCALE, calculateNotional } from "../math.ts"; -import { toBigint } from "../rational.ts"; +import { sqrt, toBigint } from "../rational.ts"; + +/** Bigint precision for the √H_sec conversion of σ_per_sec → σ_per_horizon. */ +const VOL_HORIZON_PRECISION_BITS = 48; export interface EffectiveSpreadConfig { /** Floor spread in basis points; one-side half-spread is half this. */ @@ -85,11 +93,13 @@ export function computeMidQuote(opts: { baseQuantity: bigint; maxSkewTicks: number; tick: bigint; + /** Holding-time horizon (seconds) used to scale per-second σ into bps. */ + volHorizonSec: number; }): MidQuote { - const { oracle, gas, inventory, cfg, baseQuantity, maxSkewTicks, tick } = opts; + const { oracle, gas, inventory, cfg, baseQuantity, maxSkewTicks, tick, volHorizonSec } = opts; const oraclePrice = oracle.currentPrice; - const spreadBps = effectiveSpreadBps({ oracle, gas, inventory, cfg, baseQuantity }); + const spreadBps = effectiveSpreadBps({ oracle, gas, inventory, cfg, baseQuantity, volHorizonSec }); const halfSpreadBps = spreadBps.div(new Fraction(2n)); const skewOffset = inventorySkewOffset({ @@ -113,15 +123,18 @@ function effectiveSpreadBps(opts: { inventory: InventoryManager; cfg: EffectiveSpreadConfig; baseQuantity: bigint; + volHorizonSec: number; }): Fraction { - const { oracle, gas, inventory, cfg, baseQuantity } = opts; + const { oracle, gas, inventory, cfg, baseQuantity, volHorizonSec } = opts; const gasFloor = gasFloorBps(oracle, gas, baseQuantity); const minSpread = new Fraction(cfg.minSpreadBps); const base = gasFloor.compare(minSpread) > 0 ? gasFloor : minSpread; - // vol Fraction (stddev of log returns) * multiplier * 10000 → bps - const vol = oracle.volatility + // σ_per_sec * √H_sec * multiplier * 10000 → bps + const horizonScale = horizonStddevScale(volHorizonSec); + const vol = oracle.volatilityPerSecond + .mul(horizonScale) .mul(new Fraction(Math.round(cfg.volatilityMultiplier * 1_000_000), 1_000_000)) .mul(new Fraction(10_000n)); @@ -171,3 +184,13 @@ function inventorySkewOffset(opts: { function bpsToBigint(bpsFraction: Fraction): bigint { return toBigint(bpsFraction, 1n, "nearest"); } + +/** + * √H_sec as a Fraction. Uses millisecond resolution under the hood so + * fractional-second horizons (e.g. 0.5s) round to a stable rational. + */ +function horizonStddevScale(horizonSec: number): Fraction { + if (!Number.isFinite(horizonSec) || horizonSec <= 0) return new Fraction(0n); + const ms = Math.max(1, Math.round(horizonSec * 1000)); + return sqrt(new Fraction(BigInt(ms), 1000n), VOL_HORIZON_PRECISION_BITS); +} diff --git a/market-maker/src/core/pricing/reservationPrice.ts b/market-maker/src/core/pricing/reservationPrice.ts index 1df69d4..b3da833 100644 --- a/market-maker/src/core/pricing/reservationPrice.ts +++ b/market-maker/src/core/pricing/reservationPrice.ts @@ -8,25 +8,28 @@ * * ## Formulas * - * r = S − q · γ · σ² · T (reservation price) + * r = S − q · γ · σ_s² · T (reservation price) * * half_spread_bps = max(min_half_bps, vol_half_bps) + gas_penalty_bps/2 · spike% - * vol_half_bps = σ · vol_mult · 1e4 / 2 + * vol_half_bps = σ_s · √H_sec · vol_mult · 1e4 / 2 * bid = r · (1 − half_spread_bps / 1e4) * ask = r · (1 + half_spread_bps / 1e4) * - * q = netQuantity / QUANTITY_SCALE (signed, in "contracts") - * T = max(0, deliveryDate − now) (seconds, fallback marginCallTimeSeconds) - * σ = OracleTracker.volatility (per-poll Fraction) + * q = netQuantity / QUANTITY_SCALE (signed, in "contracts") + * T = max(0, deliveryDate − now) (seconds, fallback marginCallTimeSeconds) + * H = vol horizon (seconds; defaults to pollInterval) + * σ_s = OracleTracker.volatilityPerSecond (units s^-1/2) * * ## Units * * - S, r, bid, ask : token-decimals * - q : contracts (Fraction) - * - γ (riskAversion): dimensionless; tune so q·γ·σ²·T at max inventory - * shifts r by ~1 tick - * - σ : per-poll log-return stddev - * - T : seconds + * - σ_s : per-second log-return stddev (units s^-1/2) + * - σ_s² · T : dimensionless variance over T seconds + * - γ (riskAversion): price; tunes so q·γ·σ_s²·T at max inventory shifts r + * by ~1 tick. With per-second σ, γ values are smaller + * than the per-step legacy by roughly pollIntervalSec. + * - T, H : seconds * * ## Inventory direction * @@ -35,15 +38,16 @@ * * ## Worked example * - * S = 100_000_000, σ = 0.001, γ = 0.001, q = 50 (long 50 contracts), - * T = 86_400 (1 day to delivery), min_spread = 15 bps, vol_mult = 2.5, - * tick = 1000. + * S = 100_000_000, σ_s = 5.8e-4 per √s, γ = 1e-3, q = 50, T = 86_400, + * H = 3 s, min_spread = 15 bps, vol_mult = 2.5, tick = 1000. * - * adj = 50 · 0.001 · 0.000001 · 86_400 ≈ 4.32 (price units) - * r = 100_000_000 − 4.32 ≈ 99_999_995.68 → quantize to 99_999_995 - * half = max(7.5, 0.001 · 2.5 · 1e4 / 2) = max(7.5, 12.5) = 12.5 bps - * bid = 99_999_995 · 0.99875 ≈ 99_874_995 → round down to nearest tick - * ask = 99_999_995 · 1.00125 ≈ 100_124_994 → round up + * σ_s²·T = (5.8e-4)² · 86_400 ≈ 0.0291 + * adj = 50 · 1e-3 · 0.0291 ≈ 1.45 (price units) + * r = 100_000_000 − 1.45 → quantize to 99_999_999 + * vol_half_bps = 5.8e-4 · √3 · 2.5 · 1e4 / 2 ≈ 12.5 bps + * half = max(7.5, 12.5) = 12.5 bps + * bid = 99_999_999 · 0.99875 → roundDownToTick + * ask = 99_999_999 · 1.00125 → roundUpToTick * * ## References * @@ -56,7 +60,7 @@ */ import Fraction from "fraction.js"; -import { fromNumber, fromRatio, toBigint } from "../rational.ts"; +import { fromNumber, fromRatio, sqrt, toBigint } from "../rational.ts"; import { BPS_SCALE, QUANTITY_SCALE, roundDownToTick, roundUpToTick } from "../math.ts"; import type { OracleTracker } from "../oracleTracker.ts"; import type { GasTracker } from "../gasTracker.ts"; @@ -64,6 +68,9 @@ import type { InventoryManager } from "../inventoryManager.ts"; import type { InstrumentContext } from "../adapter.ts"; import type { MidQuote } from "./effectiveSpread.ts"; +/** Bigint precision for the √H_sec conversion of σ_per_sec → σ_per_horizon. */ +const VOL_HORIZON_PRECISION_BITS = 48; + export interface ReservationPriceConfig { /** Avellaneda–Stoikov risk aversion γ. */ riskAversion: number; @@ -84,13 +91,15 @@ export function computeReservationMidQuote(opts: { context: InstrumentContext; cfg: ReservationPriceConfig; tick: bigint; + /** Holding-time horizon (seconds) used to scale per-second σ into bps. */ + volHorizonSec: number; nowMs?: number; }): MidQuote { - const { oracle, gas, inventory, context, cfg, tick, nowMs = Date.now() } = opts; + const { oracle, gas, inventory, context, cfg, tick, volHorizonSec, nowMs = Date.now() } = opts; const S = oracle.currentPrice; - // Reservation price r = S − q·γ·σ²·T (file header). - const sigma = oracle.volatility; + // Reservation price r = S − q·γ·σ_s²·T (file header). + const sigma = oracle.volatilityPerSecond; const sigma2 = sigma.mul(sigma); const gamma = fromNumber(cfg.riskAversion); @@ -105,8 +114,8 @@ export function computeReservationMidQuote(opts: { const r = rBigint > tick ? rBigint : tick; // floor at 1 tick // Symmetric half-spread around r; vol/gas widen it (file header). - const spreadBps = halfSpreadBps({ oracle, gas, cfg }).mul(new Fraction(2n)); - const halfBps = halfSpreadBps({ oracle, gas, cfg }); + const halfBps = halfSpreadBps({ oracle, gas, cfg, volHorizonSec }); + const spreadBps = halfBps.mul(new Fraction(2n)); const halfBpsBig = toBigint(halfBps, 1n, "nearest"); const bidRaw = (r * (BPS_SCALE - halfBpsBig)) / BPS_SCALE; @@ -122,11 +131,14 @@ function halfSpreadBps(opts: { oracle: OracleTracker; gas: GasTracker; cfg: ReservationPriceConfig; + volHorizonSec: number; }): Fraction { - const { oracle, gas, cfg } = opts; + const { oracle, gas, cfg, volHorizonSec } = opts; const minSpread = fromNumber(cfg.minSpreadBps / 2); // half of the full-spread floor - const volBps = oracle.volatility + const horizonScale = horizonStddevScale(volHorizonSec); + const volBps = oracle.volatilityPerSecond + .mul(horizonScale) .mul(fromNumber(cfg.volatilityMultiplier)) .mul(new Fraction(10_000n)) .div(new Fraction(2n)); @@ -140,3 +152,10 @@ function halfSpreadBps(opts: { return base.add(gasPenalty); } + +/** √H_sec as a Fraction; mirrors `effectiveSpread.horizonStddevScale`. */ +function horizonStddevScale(horizonSec: number): Fraction { + if (!Number.isFinite(horizonSec) || horizonSec <= 0) return new Fraction(0n); + const ms = Math.max(1, Math.round(horizonSec * 1000)); + return sqrt(new Fraction(BigInt(ms), 1000n), VOL_HORIZON_PRECISION_BITS); +} diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index db05f0c..a9f76e9 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -36,6 +36,13 @@ export interface QuoterConfig { * fill conditional on shallower levels filling first). */ levelSpacingTicks: number; + /** + * Holding-time horizon (seconds) used to convert per-second realized + * volatility (`OracleTracker.volatilityPerSecond`) into per-horizon log + * returns for bps math: `vol_bps ∝ σ_s · √volHorizonSec`. Set to the + * typical time between requotes — `pollIntervalSec` is a sensible default. + */ + volHorizonSec: number; } /** @@ -109,6 +116,7 @@ export class Quoter { context: this.context, cfg: this.cfg.pricing, tick: this.tick, + volHorizonSec: this.cfg.volHorizonSec, }) : computeMidQuote({ oracle: this.oracle, @@ -118,6 +126,7 @@ export class Quoter { baseQuantity: this.cfg.sizing.baseQuantity, maxSkewTicks: this.cfg.maxSkewTicks, tick: this.tick, + volHorizonSec: this.cfg.volHorizonSec, }); const { bidMid, askMid, spreadBps } = midQuote; diff --git a/market-maker/src/core/runner.ts b/market-maker/src/core/runner.ts index 001b0c3..d3dc4c7 100644 --- a/market-maker/src/core/runner.ts +++ b/market-maker/src/core/runner.ts @@ -69,7 +69,7 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { health.onStart = async () => { logger.info("start requested via API, re-initializing"); await book.start(); - await oracle.update(); + await oracle.initialize(); await gas.update(); await inventory.update(); await collateral.update(); @@ -82,7 +82,7 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { await quoter.initialize(); await gas.calibrate(() => instrument.estimateCreateGas(mmAddress)); await book.start(); - await oracle.update(); + await oracle.initialize(); await gas.update(); await inventory.update(); await collateral.update(); diff --git a/market-maker/tests/apps/futures/config.test.ts b/market-maker/tests/apps/futures/config.test.ts index b8b13e0..963a870 100644 --- a/market-maker/tests/apps/futures/config.test.ts +++ b/market-maker/tests/apps/futures/config.test.ts @@ -43,6 +43,7 @@ gas: gasCapMultiplier: 2.0 timing: {} collateral: {} +oracle: {} health: port: 8080 `; diff --git a/market-maker/tests/apps/futures/main.smoke.test.ts b/market-maker/tests/apps/futures/main.smoke.test.ts index 4beb242..c609ca5 100644 --- a/market-maker/tests/apps/futures/main.smoke.test.ts +++ b/market-maker/tests/apps/futures/main.smoke.test.ts @@ -17,6 +17,7 @@ describe("futures app config smoke", () => { PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", ALCHEMY_API_KEY: "stub-alchemy-key", FUTURES_ADDRESS: "0x1234567890123456789012345678901234567890", + HASHPRICE_ORACLE_SUBGRAPH_URL: "https://stub.example/subgraph", }; const cfg = loadFuturesConfig({ path, env }); assert.equal(cfg.venue.kind, "futures"); diff --git a/market-maker/tests/apps/perps/config.test.ts b/market-maker/tests/apps/perps/config.test.ts index ada0e4e..e11aa74 100644 --- a/market-maker/tests/apps/perps/config.test.ts +++ b/market-maker/tests/apps/perps/config.test.ts @@ -42,6 +42,7 @@ gas: timing: pollIntervalSec: 3 collateral: {} +oracle: {} health: port: 8080 `; diff --git a/market-maker/tests/apps/perps/main.smoke.test.ts b/market-maker/tests/apps/perps/main.smoke.test.ts index 035fb15..317b639 100644 --- a/market-maker/tests/apps/perps/main.smoke.test.ts +++ b/market-maker/tests/apps/perps/main.smoke.test.ts @@ -17,6 +17,7 @@ describe("perps app config smoke", () => { PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", ALCHEMY_API_KEY: "stub-alchemy-key", PERPS_ADDRESS: "0x1234567890123456789012345678901234567890", + HASHPRICE_ORACLE_SUBGRAPH_URL: "https://stub.example/subgraph", }; const cfg = loadPerpsConfig({ path, env }); assert.equal(cfg.venue.kind, "perps"); diff --git a/market-maker/tests/core/math.test.ts b/market-maker/tests/core/math.test.ts index 76653ab..01bcf8c 100644 --- a/market-maker/tests/core/math.test.ts +++ b/market-maker/tests/core/math.test.ts @@ -102,6 +102,76 @@ describe("RollingWindow (bigint samples, Fraction volatility)", () => { }); }); +describe("RollingWindow per-second volatility", () => { + // σ_per_sec Fractions can have 1000+ bit numerators/denominators (sqrt at + // 48-bit precision), which blows up `Number(bigint) / Number(bigint)` to + // Infinity/Infinity = NaN. `simplify` collapses the magnitude first. + const fracVal = (f: ReturnType): number => + f.simplify(1e-12).valueOf(); + + it("constant prices → zero per-second vol", () => { + const w = new RollingWindow(10); + for (let t = 0; t < 5; t++) w.push(100n, t); + assert.equal(fracVal(w.volatilityPerSecond()), 0); + }); + + it("uniform Δt: σ_per_sec ≈ σ_per_step / √Δt", () => { + const w = new RollingWindow(10); + const prices = [100n, 102n, 98n, 101n, 99n, 103n]; + const dt = 4; // seconds between samples + for (let i = 0; i < prices.length; i++) w.push(prices[i], i * dt); + const perStep = w.volatility().simplify(1e-12).valueOf(); + const perSec = fracVal(w.volatilityPerSecond()); + // For uniform Δt the relationship is exact: σ_step = σ_sec · √Δt. + assert.ok( + Math.abs(perStep - perSec * Math.sqrt(dt)) < 1e-9, + `expected σ_step=${perStep} ≈ σ_sec=${perSec} × √${dt}`, + ); + }); + + it("non-uniform Δt: per-second σ rescales with √Δt", () => { + // Two windows with identical price moves but different sampling intervals. + // Per-step σ is the same; per-second σ differs by exactly √(slowDt/fastDt). + const fast = new RollingWindow(20); + const slow = new RollingWindow(20); + const moves = [1.005, 0.995, 1.01, 0.99, 1.008, 0.992, 1.003, 0.997]; + let pf = 1_000_000n; + let ps = 1_000_000n; + for (let i = 0; i < moves.length; i++) { + pf = BigInt(Math.round(Number(pf) * moves[i])); + ps = BigInt(Math.round(Number(ps) * moves[i])); + fast.push(pf, i * 1); // Δt = 1s + slow.push(ps, i * 4); // Δt = 4s + } + const fastSec = fracVal(fast.volatilityPerSecond()); + const slowSec = fracVal(slow.volatilityPerSecond()); + const ratio = fastSec / slowSec; + assert.ok( + Math.abs(ratio - 2) < 1e-9, + `expected fast/slow ≈ 2 (√4), got ${ratio} (fastSec=${fastSec}, slowSec=${slowSec})`, + ); + }); + + it("returns 0 when timestamps are missing", () => { + const w = new RollingWindow(10); + w.push(100n); + w.push(110n); + w.push(105n); + w.push(108n); + assert.equal(fracVal(w.volatilityPerSecond()), 0); + }); + + it("ignores non-monotonic timestamps", () => { + const w = new RollingWindow(10); + // All deltas non-positive → no usable returns → σ = 0. + w.push(100n, 100); + w.push(110n, 100); + w.push(105n, 99); + w.push(108n, 98); + assert.equal(fracVal(w.volatilityPerSecond()), 0); + }); +}); + describe("RollingBudget", () => { it("sums entries within window", () => { const b = new RollingBudget(60_000); diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index 113306a..bbbf481 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -1,7 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import type Fraction from "fraction.js"; import { OracleTracker } from "../../src/core/oracleTracker.ts"; import type { InstrumentAdapter } from "../../src/core/adapter.ts"; +import type { + HistoricalPriceSource, + PricePoint, +} from "../../src/core/historicalPriceSource.ts"; const noop = () => {}; function makeLogger(): never { @@ -33,11 +38,34 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { }; } +function makeHistory(points: PricePoint[]): HistoricalPriceSource { + return { fetch: async () => points }; +} + +/** A monotonically advancing clock so successive updates produce distinct timestamps. */ +function makeClock(stepSec = 1): () => number { + let t = 1_700_000_000; + return () => { + const out = t; + t += stepSec; + return out; + }; +} + +/** + * `volatilityPerSecond` accumulates a Fraction with very large numerator and + * denominator (sqrt at 48-bit precision); a naive `.valueOf()` overflows to + * Infinity/Infinity = NaN. `simplify` collapses the magnitude before the cast. + */ +function fracVal(f: Fraction): number { + return f.simplify(1e-12).valueOf(); +} + describe("OracleTracker", () => { - it("starts with zero price and zero volatility", () => { + it("starts with zero price and zero per-second volatility", () => { const tracker = new OracleTracker(makeInstrument([0n]), makeLogger()); assert.equal(tracker.currentPrice, 0n); - assert.equal(tracker.volatility.valueOf(), 0); + assert.equal(fracVal(tracker.volatilityPerSecond), 0); }); it("updates price from instrument", async () => { @@ -46,25 +74,92 @@ describe("OracleTracker", () => { assert.equal(tracker.currentPrice, 100_000_000n); }); - it("computes non-zero volatility from varying samples", async () => { + it("computes non-zero per-second volatility from varying samples", async () => { const prices = [100_000_000n, 101_000_000n, 99_000_000n, 102_000_000n]; - const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + const tracker = new OracleTracker(makeInstrument(prices), makeLogger(), { + nowSec: makeClock(), + }); for (let i = 0; i < prices.length; i++) await tracker.update(); - assert.ok(tracker.volatility.valueOf() > 0, "expected positive vol"); + assert.ok( + fracVal(tracker.volatilityPerSecond) > 0, + "expected positive per-second vol", + ); }); it("volatility is 0 with fewer than 3 samples", async () => { const prices = [100_000_000n, 101_000_000n]; - const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + const tracker = new OracleTracker(makeInstrument(prices), makeLogger(), { + nowSec: makeClock(), + }); await tracker.update(); await tracker.update(); - assert.equal(tracker.volatility.valueOf(), 0); + assert.equal(fracVal(tracker.volatilityPerSecond), 0); }); it("ignores zero/negative prices in the window", async () => { const prices = [0n, 0n, 0n, 100_000_000n]; - const tracker = new OracleTracker(makeInstrument(prices), makeLogger()); + const tracker = new OracleTracker(makeInstrument(prices), makeLogger(), { + nowSec: makeClock(), + }); for (let i = 0; i < prices.length; i++) await tracker.update(); - assert.equal(tracker.volatility.valueOf(), 0); + assert.equal(fracVal(tracker.volatilityPerSecond), 0); + }); + + it("de-duplicates repeat polls so an unchanging oracle does not bias σ to 0", async () => { + // The oracle returns the same value 6 times in a row, then ticks twice. + // Old behaviour pushed all 8 polls and built 7 zero-returns plus 1 nonzero; + // new behaviour pushes only the 3 distinct prices, so σ is computed from + // 2 nonzero log returns rather than 7 zeros. + const prices = [100n, 100n, 100n, 100n, 100n, 100n, 110n, 90n]; + const tracker = new OracleTracker(makeInstrument(prices), makeLogger(), { + nowSec: makeClock(), + }); + for (let i = 0; i < prices.length; i++) await tracker.update(); + assert.ok( + fracVal(tracker.volatilityPerSecond) > 0, + "expected positive σ — duplicates should not crowd the window", + ); + }); + + it("backfills the window from a historical source on initialize()", async () => { + // Prices change every step, so the per-step log returns are all non-trivial + // and σ is well above zero after backfill alone. + const now = 1_700_000_000; + const history = makeHistory([ + { timestampSec: now - 30, price: 100n }, + { timestampSec: now - 20, price: 110n }, + { timestampSec: now - 10, price: 95n }, + { timestampSec: now - 5, price: 105n }, + ]); + const tracker = new OracleTracker(makeInstrument([105n]), makeLogger(), { + history, + pollIntervalMs: 10_000, + windowSize: 60, + // Live tick lands ~5s after the last backfilled sample so it pushes too. + nowSec: () => now, + }); + await tracker.initialize(); + assert.ok( + fracVal(tracker.volatilityPerSecond) > 0, + `expected positive σ after backfill, got ${fracVal(tracker.volatilityPerSecond)}`, + ); + }); + + it("backfill failures fall back gracefully to live warm-up", async () => { + const failing: HistoricalPriceSource = { + fetch: async () => { + throw new Error("subgraph unavailable"); + }, + }; + const tracker = new OracleTracker(makeInstrument([100n]), makeLogger(), { + history: failing, + pollIntervalMs: 1000, + nowSec: makeClock(), + }); + // initialize() must not throw even when backfill errors out — startup is + // not allowed to depend on the subgraph being reachable. + await tracker.initialize(); + assert.equal(tracker.currentPrice, 100n); + assert.equal(fracVal(tracker.volatilityPerSecond), 0); }); }); diff --git a/market-maker/tests/core/pricing/reservationPrice.test.ts b/market-maker/tests/core/pricing/reservationPrice.test.ts index f8b01cc..6561c77 100644 --- a/market-maker/tests/core/pricing/reservationPrice.test.ts +++ b/market-maker/tests/core/pricing/reservationPrice.test.ts @@ -9,9 +9,10 @@ import type { InventoryManager } from "../../../src/core/inventoryManager.ts"; import type { InstrumentContext } from "../../../src/core/adapter.ts"; const TICK = 1_000n; // $0.001 in 6-decimal USDC +const HORIZON_SEC = 3; // matches the typical pollIntervalSec used by configs function makeOracle(price: bigint, vol = new Fraction(0n)): OracleTracker { - return { currentPrice: price, volatility: vol } as unknown as OracleTracker; + return { currentPrice: price, volatilityPerSecond: vol } as unknown as OracleTracker; } function makeGas(spikePct = new Fraction(0n), roundTripUsd = 0n): GasTracker { @@ -39,6 +40,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg: baseCfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); assert.ok(bidMid < askMid, `bid ${bidMid} should be < ask ${askMid}`); assert.ok(bidMid > 0n); @@ -55,6 +57,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); const longInv = computeReservationMidQuote({ oracle: makeOracle(oracle, new Fraction(1n, 100n)), @@ -63,6 +66,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); assert.ok(longInv.bidMid <= noInv.bidMid, "long inventory should push bid mid down or equal"); }); @@ -78,6 +82,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); const shortInv = computeReservationMidQuote({ oracle: makeOracle(oracle, new Fraction(1n, 100n)), @@ -86,6 +91,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); assert.ok(shortInv.askMid >= noInv.askMid, "short inventory should push ask mid up or equal"); }); @@ -102,6 +108,7 @@ describe("computeReservationMidQuote", () => { context, cfg: baseCfg, tick: TICK, + volHorizonSec: HORIZON_SEC, nowMs, }); assert.ok(bidMid < askMid); @@ -121,6 +128,7 @@ describe("computeReservationMidQuote", () => { context: { deliveryDate: pastDelivery }, cfg, tick: TICK, + volHorizonSec: HORIZON_SEC, nowMs, }); const noDelivery = computeReservationMidQuote({ @@ -130,6 +138,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg: { ...cfg, riskAversion: 0 }, tick: TICK, + volHorizonSec: HORIZON_SEC, nowMs, }); // With T=0, adjustment = 0 regardless of inventory; reservation price = oracle @@ -146,6 +155,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg: baseCfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }); assert.strictEqual(bidMid % TICK, 0n, `bid ${bidMid} not aligned to tick ${TICK}`); assert.strictEqual(askMid % TICK, 0n, `ask ${askMid} not aligned to tick ${TICK}`); @@ -159,6 +169,7 @@ describe("computeReservationMidQuote", () => { context: {}, cfg: baseCfg, tick: TICK, + volHorizonSec: HORIZON_SEC, }; const lowVol = computeReservationMidQuote({ ...opts, oracle: makeOracle(1_000_000_000n, new Fraction(1n, 1000n)) }); From 92c08e8682a654cec7d2fd421a82f86f8a6ca500 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 20:12:05 +0200 Subject: [PATCH 017/180] fix: add indexer env var --- .github/workflows/deploy-col-mar-mm.yml | 98 +++++++++++++++---------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index 1683da4..bb3d839 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -34,14 +34,14 @@ on: - dev - stg - main - - 'cicd/**' + - "cicd/**" paths: - - 'market-maker/**' - - '.github/workflows/deploy-col-mar-mm.yml' + - "market-maker/**" + - ".github/workflows/deploy-col-mar-mm.yml" workflow_dispatch: inputs: environment: - description: 'Target environment (dev=DEV, stg=STG, main=LMN/PROD)' + description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" required: true type: choice options: @@ -50,9 +50,9 @@ on: - main permissions: - id-token: write # Required for OIDC - contents: write # Required for creating git tags on main - packages: write # For GHCR + id-token: write # Required for OIDC + contents: write # Required for creating git tags on main + packages: write # For GHCR env: COMPONENT: col-mar-mm @@ -91,8 +91,9 @@ jobs: uses: ./.github/actions/gen-tag with: component: col-mar-mm - major_version: '1' - environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + major_version: "1" + environment_override: ${{ github.event_name == 'workflow_dispatch' && + github.event.inputs.environment || '' }} - name: Environment config id: env_config @@ -194,20 +195,23 @@ jobs: strategy: fail-fast: false matrix: - app: [perps, futures] + app: [ perps, futures ] steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && + secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' + && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-${{ github.run_id }} - name: Verify service exists id: svc_check env: - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || + needs.build.outputs.futures_service }} CLUSTER: ${{ needs.build.outputs.ecs_cluster }} REGION: ${{ needs.build.outputs.aws_region }} run: | @@ -231,33 +235,37 @@ jobs: if: steps.svc_check.outputs.skip != 'true' env: # Routing - CLUSTER: ${{ needs.build.outputs.ecs_cluster }} - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} - TASK_FAMILY: ${{ matrix.app == 'perps' && needs.build.outputs.perps_task_family || needs.build.outputs.futures_task_family }} - REGION: ${{ needs.build.outputs.aws_region }} - IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || + needs.build.outputs.futures_service }} + TASK_FAMILY: ${{ matrix.app == 'perps' && needs.build.outputs.perps_task_family + || needs.build.outputs.futures_task_family }} + REGION: ${{ needs.build.outputs.aws_region }} + IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} # Per-environment GitHub Variables (non-secret personality) - MAKER_LOG_LEVEL: ${{ vars.MAKER_LOG_LEVEL }} - MAKER_DRY_RUN: ${{ vars.MAKER_DRY_RUN }} + MAKER_LOG_LEVEL: ${{ vars.MAKER_LOG_LEVEL }} + MAKER_DRY_RUN: ${{ vars.MAKER_DRY_RUN }} MAKER_CANCEL_ORDERS_ON_SHUTDOWN: ${{ vars.MAKER_CANCEL_ORDERS_ON_SHUTDOWN }} - MAKER_HEALTH_PORT: ${{ vars.MAKER_HEALTH_PORT }} - MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} - ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} + MAKER_HEALTH_PORT: ${{ vars.MAKER_HEALTH_PORT }} + MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} + ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} # Per-environment GitHub Secrets (encrypted personality). Secrets # only flow into this step's process env via inline ternaries; they # are never written to step outputs or to disk on the runner. ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} - PRIVATE_KEY: ${{ matrix.app == 'perps' && secrets.PERPS_MM_PRIVATE_KEY || secrets.FUTURES_MM_PRIVATE_KEY }} + PRIVATE_KEY: ${{ matrix.app == 'perps' && secrets.PERPS_MM_PRIVATE_KEY || + secrets.FUTURES_MM_PRIVATE_KEY }} # Per-venue config selected by matrix.app - VENUE_ADDR_NAME: ${{ matrix.app == 'perps' && 'PERPS_ADDRESS' || 'FUTURES_ADDRESS' }} - VENUE_ADDR_VALUE: ${{ matrix.app == 'perps' && vars.PERPS_ADDRESS || vars.FUTURES_ADDRESS }} + VENUE_ADDR_NAME: ${{ matrix.app == 'perps' && 'PERPS_ADDRESS' || 'FUTURES_ADDRESS' }} + VENUE_ADDR_VALUE: ${{ matrix.app == 'perps' && vars.PERPS_ADDRESS || + vars.FUTURES_ADDRESS }} # Computed - MAKER_APP: ${{ matrix.app }} - MAKER_ENV: ${{ needs.build.outputs.maker_env }} + MAKER_APP: ${{ matrix.app }} + MAKER_ENV: ${{ needs.build.outputs.maker_env }} COMMIT_HASH: ${{ github.sha }} run: | set -euo pipefail @@ -301,6 +309,7 @@ jobs: --arg PRIVATE_KEY "${PRIVATE_KEY}" \ --arg VENUE_ADDR_NAME "${VENUE_ADDR_NAME}" \ --arg VENUE_ADDR_VALUE "${VENUE_ADDR_VALUE}" \ + --arg HASHPRICE_ORACLE_SUBGRAPH_URL "${HASHPRICE_ORACLE_SUBGRAPH_URL}" \ '[ {name:"MAKER_APP", value:$MAKER_APP}, {name:"MAKER_ENV", value:$MAKER_ENV}, @@ -312,7 +321,8 @@ jobs: {name:"ETH_PRICE_FEED_ADDRESS", value:$ETH_PRICE_FEED_ADDRESS}, {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, {name:"PRIVATE_KEY", value:$PRIVATE_KEY}, - {name:$VENUE_ADDR_NAME, value:$VENUE_ADDR_VALUE} + {name:$VENUE_ADDR_NAME, value:$VENUE_ADDR_VALUE}, + {name:"HASHPRICE_ORACLE_SUBGRAPH_URL", value:$HASHPRICE_ORACLE_SUBGRAPH_URL} ]' > env-block.json # Replace the entire env block; also drop any stale `secrets` block @@ -346,26 +356,29 @@ jobs: verify: name: 🔍 Verify ${{ matrix.app }} runs-on: ubuntu-latest - needs: [build, deploy] + needs: [ build, deploy ] if: needs.build.outputs.is_cicd_branch != 'true' strategy: fail-fast: false matrix: - app: [perps, futures] + app: [ perps, futures ] steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && + secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' + && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-Verify-${{ github.run_id }} - name: Wait for service to stabilize env: - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || needs.build.outputs.futures_service }} + SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || + needs.build.outputs.futures_service }} CLUSTER: ${{ needs.build.outputs.ecs_cluster }} - REGION: ${{ needs.build.outputs.aws_region }} + REGION: ${{ needs.build.outputs.aws_region }} run: | ACTIVE=$(aws ecs describe-services \ --cluster "$CLUSTER" \ @@ -388,7 +401,7 @@ jobs: cleanup: name: 🧹 Cleanup runs-on: ubuntu-latest - needs: [build, verify] + needs: [ build, verify ] if: always() && needs.build.outputs.is_cicd_branch != 'true' steps: @@ -443,7 +456,7 @@ jobs: notify: name: 📢 Notify runs-on: ubuntu-latest - needs: [build, deploy, verify, cleanup] + needs: [ build, deploy, verify, cleanup ] if: always() && (needs.build.result == 'success') steps: @@ -472,9 +485,18 @@ jobs: with: status: ${{ steps.status.outputs.status }} environment: ${{ needs.build.outputs.environment }} - service_name: 'Collateral Margin Market Maker' + service_name: "Collateral Margin Market Maker" version: ${{ needs.build.outputs.version }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} github_token: ${{ secrets.GITHUB_TOKEN }} - image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} - additional_info: "${{ needs.build.outputs.is_cicd_branch == 'true' && '*Mode:* CI/CD Test (build only, no deploy)' || format('*Health:* <{0}|Perps> <{1}|Futures>\n*Cluster:* `{2}` • *Services:* `{3}`, `{4}`', needs.build.outputs.perps_health_url, needs.build.outputs.futures_health_url, needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, needs.build.outputs.futures_service) }}" + image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', + env.GHCR_IMAGE, needs.build.outputs.version) || '' }} + additional_info: "${{ needs.build.outputs.is_cicd_branch == 'true' && '*Mode:* + CI/CD Test (build only, no deploy)' || format('*Health:* <{0}|Perps> + <{1}|Futures> + + *Cluster:* `{2}` • *Services:* `{3}`, `{4}`', + needs.build.outputs.perps_health_url, + needs.build.outputs.futures_health_url, + needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, + needs.build.outputs.futures_service) }}" From dbe703101a74b0e50f0833204b66a21ee961b4ef Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 20:17:23 +0200 Subject: [PATCH 018/180] fix: add missing HASHPRICE_ORACLE_SUBGRAPH_URL var --- .github/workflows/deploy-col-mar-mm.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index bb3d839..5564496 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -250,6 +250,7 @@ jobs: MAKER_HEALTH_PORT: ${{ vars.MAKER_HEALTH_PORT }} MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} + HASHPRICE_ORACLE_SUBGRAPH_URL: ${{ vars.HASHPRICE_ORACLE_SUBGRAPH_URL }} # Per-environment GitHub Secrets (encrypted personality). Secrets # only flow into this step's process env via inline ternaries; they From 8fb3c5e875c7ef7ff6713c2a62b46516e73ea877 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 12 May 2026 16:27:38 +0200 Subject: [PATCH 019/180] feat: add depositForPermit function to CollateralVault and introduce FuturesMock for testing --- contracts/abi/CollateralVault.json | 38 ++++++++ contracts/abi/CollateralVault.ts | 38 ++++++++ contracts/contracts/mocks/FuturesMock.sol | 53 ++++++++++ .../tests/crossMarginIntegration.test.ts | 96 +++++++++++++++++++ contracts/tests/fixtures.ts | 19 +++- 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 contracts/contracts/mocks/FuturesMock.sol diff --git a/contracts/abi/CollateralVault.json b/contracts/abi/CollateralVault.json index 16a5ab8..b2b9369 100644 --- a/contracts/abi/CollateralVault.json +++ b/contracts/abi/CollateralVault.json @@ -603,6 +603,44 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "depositForPermit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/abi/CollateralVault.ts b/contracts/abi/CollateralVault.ts index 10ff645..8c77b0b 100644 --- a/contracts/abi/CollateralVault.ts +++ b/contracts/abi/CollateralVault.ts @@ -603,6 +603,44 @@ export const CollateralVaultAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "depositForPermit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/contracts/mocks/FuturesMock.sol b/contracts/contracts/mocks/FuturesMock.sol new file mode 100644 index 0000000..e9ae39c --- /dev/null +++ b/contracts/contracts/mocks/FuturesMock.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IFutures } from "../interfaces/IFutures.sol"; + +/// @title FuturesMock — Minimal mock of the Futures contract for PME tests +/// @notice Direct analogue of `PerpsDEXMock`: lets tests pin the per-user +/// IFutures view outputs (`getNetPositionDelta`, +/// `getFuturesOrderMargin`, `getFuturesUnrealizedPnl`) and the +/// shared market price. All views default to zero so a fresh mock +/// is a no-op contributor to portfolio margin. +contract FuturesMock is IFutures { + mapping(address => int256) private _netDelta; + mapping(address => uint256) private _orderMargin; + mapping(address => int256) private _unrealizedPnl; + uint256 private _marketPrice; + + function decimals() external pure returns (uint8) { + return 6; // USDC + } + + function setMarketPrice(uint256 price) external { + _marketPrice = price; + } + + function getMarketPrice() external view returns (uint256) { + return _marketPrice; + } + + function setNetPositionDelta(address user, int256 delta) external { + _netDelta[user] = delta; + } + + function getNetPositionDelta(address user) external view returns (int256) { + return _netDelta[user]; + } + + function setFuturesOrderMargin(address user, uint256 om) external { + _orderMargin[user] = om; + } + + function getFuturesOrderMargin(address user) external view returns (uint256) { + return _orderMargin[user]; + } + + function setFuturesUnrealizedPnl(address user, int256 pnl) external { + _unrealizedPnl[user] = pnl; + } + + function getFuturesUnrealizedPnl(address user) external view returns (int256) { + return _unrealizedPnl[user]; + } +} diff --git a/contracts/tests/crossMarginIntegration.test.ts b/contracts/tests/crossMarginIntegration.test.ts index 2042cd5..399cce9 100644 --- a/contracts/tests/crossMarginIntegration.test.ts +++ b/contracts/tests/crossMarginIntegration.test.ts @@ -218,6 +218,102 @@ describe("Cross-Margin Integration", () => { }); }); + describe("futures leg in cross-margin engine", () => { + // Each futures contract over D delivery days contributes delta = D * WAD; + // the FuturesMock returns whatever delta the test pins. A delta of 7e18 + // (one 7-day contract) at $50k spot with 10% IM stress = $35k loss in WAD, + // i.e. 35_000_000_000 token units (6 decimals). + const ONE_WEEK_DAYS_WAD = 7n * 10n ** 18n; + const SEVEN_DAY_LONG_IM = 35_000_000_000n; // |7e18| * 10% * $50k → 35k USDC + const SEVEN_DAY_LONG_MM = 17_500_000_000n; // 5% MM = 17.5k USDC + + it("futures-only IM gates withdrawal", async () => { + const { vault, alice, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + // Alice has 50k USDC. One 7-day long contract → IM = $35k. + // Withdrawing 30k would leave 20k < 35k IM, so it must revert. + await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DAYS_WAD]); + + await viem.assertions.revertWithCustomError( + vault.write.withdraw([30_000_000_000n], { account: alice.account }), + vault, + "MarginBreach", + ); + }); + + it("perps long offsets futures short net delta in stress test", async () => { + const { vault, alice, perpsMock, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + const withdrawAmount = 40_000_000_000n; + + // Pure futures short → IM = $35k → withdraw 40k must fail. + await futuresMock.write.setNetPositionDelta([aliceAddr, -ONE_WEEK_DAYS_WAD]); + await viem.assertions.revertWithCustomError( + vault.write.withdraw([withdrawAmount], { account: alice.account }), + vault, + "MarginBreach", + ); + + // Add a perp long that offsets the futures short delta-for-delta. With + // net portfolio delta ≈ 0 the stress loss collapses, so 40k withdraw + // succeeds (only the perp's order/position add-ons remain — both zero). + // Perp delta = qty * WAD / 10^QUANTITY_DECIMALS. To offset 7e18 we need + // qty = 7e18 * 1e6 / 1e18 = 7_000_000. + await perpsMock.write.setUserPosition([aliceAddr, 7_000_000n, DEFAULT_MARKET_PRICE]); + + await viem.assertions.emitWithArgs( + vault.write.withdraw([withdrawAmount], { account: alice.account }), + vault, + "Withdrawn", + [getAddress(aliceAddr), withdrawAmount, getAddress(alice.account.address)], + ); + }); + + it("aggregates futures order margin + futures unrealized loss + perps order margin", async () => { + const { pme, perpsMock, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + const futuresOrder = 4_000_000_000n; + const futuresLoss = -2_500_000_000n; + const perpsOrder = 1_500_000_000n; + + await futuresMock.write.setFuturesOrderMargin([aliceAddr, futuresOrder]); + await futuresMock.write.setFuturesUnrealizedPnl([aliceAddr, futuresLoss]); + await perpsMock.write.setOrderMargin([aliceAddr, perpsOrder]); + + const im = await pme.read.computePortfolioIM([aliceAddr]); + // No net delta → no stress loss; only the linear add-ons aggregate. + assert.equal(im, futuresOrder + perpsOrder + 2_500_000_000n, "linear components add"); + }); + + it("PME isHealthy reflects futures-driven MM breach", async () => { + const { pme, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + assert.equal(await pme.read.isHealthy([aliceAddr]), true, "no positions = healthy"); + + // 7-day long → MM = 5% * $50k = $17.5k (well under 50k balance). + await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DAYS_WAD]); + assert.equal(await pme.read.isHealthy([aliceAddr]), true, "small futures MM still healthy"); + assert.ok(SEVEN_DAY_LONG_MM < INTEGRATION_ALICE_DEPOSIT); + assert.ok(SEVEN_DAY_LONG_IM < INTEGRATION_ALICE_DEPOSIT); + + // Scale the delta until MM exceeds 50k. 5e19 delta * 5% * 50k = 125k WAD = $125k. + await futuresMock.write.setNetPositionDelta([aliceAddr, 5n * 10n ** 19n]); + assert.equal( + await pme.read.isHealthy([aliceAddr]), + false, + "large futures delta pushes MM > balance", + ); + }); + }); + describe("ERC20 receipt token", () => { it("vault balanceOf matches deposit", async () => { const { vault, aliceAddr } = await networkHelpers.loadFixture( diff --git a/contracts/tests/fixtures.ts b/contracts/tests/fixtures.ts index d0bdd0a..6fc44e6 100644 --- a/contracts/tests/fixtures.ts +++ b/contracts/tests/fixtures.ts @@ -39,6 +39,8 @@ export async function deployPortfolioMarginEngineStack( const perpsMock = await viem.deployContract("PerpsDEXMock", []); await perpsMock.write.setMarketPrice([DEFAULT_MARKET_PRICE]); const optionsMock = await viem.deployContract("OptionsEngineMock", []); + const futuresMock = await viem.deployContract("FuturesMock", []); + await futuresMock.write.setMarketPrice([DEFAULT_MARKET_PRICE]); const pmeImpl = await viem.deployContract("PortfolioMarginEngine", []); const pmeProxy = await viem.deployContract("ERC1967Proxy", [ pmeImpl.address as `0x${string}`, @@ -51,7 +53,8 @@ export async function deployPortfolioMarginEngineStack( const pme = await viem.getContractAt("PortfolioMarginEngine", pmeProxy.address); await pme.write.setPerps([perpsMock.address]); await pme.write.setOptions([optionsMock.address]); - return { perpsMock, optionsMock, pme }; + await pme.write.setFutures([futuresMock.address]); + return { perpsMock, optionsMock, futuresMock, pme }; } /** CollateralVault tests: fund alice, bob, engine; approvals for deposit flows. */ @@ -89,14 +92,17 @@ export async function deployPortfolioMarginEngineFixture(conn: NetworkConnection const { viem } = conn; const [owner] = await viem.getWalletClients(); const { usdc, vault } = await deployCollateralVaultProxy(conn); - const { perpsMock, optionsMock, pme } = await deployPortfolioMarginEngineStack(conn, vault.address); + const { perpsMock, optionsMock, futuresMock, pme } = await deployPortfolioMarginEngineStack( + conn, + vault.address, + ); const user = owner.account.address; await usdc.write.approve([vault.address, maxUint256], { account: owner.account }); await vault.write.deposit([PME_OWNER_DEPOSIT], { account: owner.account }); await vault.write.setMarginEngine([pme.address], { account: owner.account }); - return { vault, perpsMock, optionsMock, pme, usdc, user, owner }; + return { vault, perpsMock, optionsMock, futuresMock, pme, usdc, user, owner }; } /** End-to-end: vault + PME + product mocks, Alice funded and deposited. */ @@ -104,11 +110,15 @@ export async function deployCrossMarginIntegrationFixture(conn: NetworkConnectio const { viem } = conn; const [owner, alice] = await viem.getWalletClients(); const { usdc, vault } = await deployCollateralVaultProxy(conn); - const { perpsMock, optionsMock, pme } = await deployPortfolioMarginEngineStack(conn, vault.address); + const { perpsMock, optionsMock, futuresMock, pme } = await deployPortfolioMarginEngineStack( + conn, + vault.address, + ); await vault.write.setMarginEngine([pme.address], { account: owner.account }); await vault.write.setAuthorizedCaller([perpsMock.address, true], { account: owner.account }); await vault.write.setAuthorizedCaller([optionsMock.address, true], { account: owner.account }); + await vault.write.setAuthorizedCaller([futuresMock.address, true], { account: owner.account }); const aliceAddr = alice.account.address; await usdc.write.transfer([aliceAddr, INTEGRATION_ALICE_TRANSFER], { account: owner.account }); @@ -121,6 +131,7 @@ export async function deployCrossMarginIntegrationFixture(conn: NetworkConnectio pme, perpsMock, optionsMock, + futuresMock, usdc, owner, alice, From edf945bce111a5cea8326786758c770221875d17 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 13 May 2026 17:31:03 +0200 Subject: [PATCH 020/180] feat: initialize collateral-margin keeper with Docker setup, configuration, and core functionality --- keeper/.dockerignore | 5 + keeper/.gitignore | 5 + keeper/Dockerfile | 25 + keeper/README.md | 244 +++++++++ keeper/package.json | 34 ++ keeper/pnpm-lock.yaml | 477 ++++++++++++++++++ keeper/pnpm-workspace.yaml | 1 + keeper/scripts/fix-placeholder-manifests.ts | 111 ++++ keeper/src/alert/notifier.ts | 185 +++++++ keeper/src/chain.ts | 31 ++ keeper/src/config.ts | 218 ++++++++ keeper/src/coordinator/executor.ts | 166 ++++++ keeper/src/coordinator/planner.ts | 309 ++++++++++++ keeper/src/coordinator/queue.ts | 95 ++++ keeper/src/discovery/tracker.ts | 470 +++++++++++++++++ keeper/src/discovery/webhook.ts | 198 ++++++++ keeper/src/index.ts | 190 +++++++ keeper/src/oracle/abi.ts | 20 + keeper/src/oracle/priceFeed.ts | 183 +++++++ keeper/src/pme/health.ts | 104 ++++ keeper/src/predict/coordinator.ts | 341 +++++++++++++ keeper/src/predict/mm.ts | 157 ++++++ keeper/src/predict/predictiveIndex.ts | 172 +++++++ keeper/src/predict/snapshot.ts | 166 ++++++ keeper/src/predict/solve.ts | 218 ++++++++ keeper/src/predict/types.ts | 92 ++++ keeper/src/runtime/healthcheck.ts | 128 +++++ keeper/src/runtime/scheduler.ts | 132 +++++ keeper/src/tx/liquidate.ts | 163 ++++++ keeper/src/venues/futures.ts | 188 +++++++ keeper/src/venues/perps.ts | 173 +++++++ keeper/src/venues/types.ts | 84 +++ keeper/tests/alert/notifier.test.ts | 194 +++++++ keeper/tests/coordinator/planner.test.ts | 321 ++++++++++++ keeper/tests/coordinator/queue.test.ts | 123 +++++ keeper/tests/discovery/tracker.test.ts | 352 +++++++++++++ keeper/tests/discovery/webhook.test.ts | 155 ++++++ keeper/tests/oracle/priceFeed.test.ts | 159 ++++++ keeper/tests/pme/health.test.ts | 161 ++++++ keeper/tests/predict/coordinator.test.ts | 310 ++++++++++++ .../tests/predict/coordinatorAlerts.test.ts | 254 ++++++++++ keeper/tests/predict/mm.test.ts | 247 +++++++++ keeper/tests/predict/predictiveIndex.test.ts | 116 +++++ keeper/tests/predict/snapshot.test.ts | 156 ++++++ keeper/tests/predict/solve.test.ts | 266 ++++++++++ keeper/tests/runtime/healthcheck.test.ts | 194 +++++++ keeper/tests/runtime/scheduler.test.ts | 281 +++++++++++ keeper/tests/tx/liquidate.test.ts | 297 +++++++++++ keeper/tests/venues/futures-marketid.test.ts | 19 + keeper/tests/venues/futures.test.ts | 216 ++++++++ keeper/tests/venues/perps.test.ts | 166 ++++++ keeper/tsconfig.json | 16 + .../src/adapters/futures/ownOrders.ts | 15 +- 53 files changed, 9095 insertions(+), 8 deletions(-) create mode 100644 keeper/.dockerignore create mode 100644 keeper/.gitignore create mode 100644 keeper/Dockerfile create mode 100644 keeper/README.md create mode 100644 keeper/package.json create mode 100644 keeper/pnpm-lock.yaml create mode 100644 keeper/pnpm-workspace.yaml create mode 100644 keeper/scripts/fix-placeholder-manifests.ts create mode 100644 keeper/src/alert/notifier.ts create mode 100644 keeper/src/chain.ts create mode 100644 keeper/src/config.ts create mode 100644 keeper/src/coordinator/executor.ts create mode 100644 keeper/src/coordinator/planner.ts create mode 100644 keeper/src/coordinator/queue.ts create mode 100644 keeper/src/discovery/tracker.ts create mode 100644 keeper/src/discovery/webhook.ts create mode 100644 keeper/src/index.ts create mode 100644 keeper/src/oracle/abi.ts create mode 100644 keeper/src/oracle/priceFeed.ts create mode 100644 keeper/src/pme/health.ts create mode 100644 keeper/src/predict/coordinator.ts create mode 100644 keeper/src/predict/mm.ts create mode 100644 keeper/src/predict/predictiveIndex.ts create mode 100644 keeper/src/predict/snapshot.ts create mode 100644 keeper/src/predict/solve.ts create mode 100644 keeper/src/predict/types.ts create mode 100644 keeper/src/runtime/healthcheck.ts create mode 100644 keeper/src/runtime/scheduler.ts create mode 100644 keeper/src/tx/liquidate.ts create mode 100644 keeper/src/venues/futures.ts create mode 100644 keeper/src/venues/perps.ts create mode 100644 keeper/src/venues/types.ts create mode 100644 keeper/tests/alert/notifier.test.ts create mode 100644 keeper/tests/coordinator/planner.test.ts create mode 100644 keeper/tests/coordinator/queue.test.ts create mode 100644 keeper/tests/discovery/tracker.test.ts create mode 100644 keeper/tests/discovery/webhook.test.ts create mode 100644 keeper/tests/oracle/priceFeed.test.ts create mode 100644 keeper/tests/pme/health.test.ts create mode 100644 keeper/tests/predict/coordinator.test.ts create mode 100644 keeper/tests/predict/coordinatorAlerts.test.ts create mode 100644 keeper/tests/predict/mm.test.ts create mode 100644 keeper/tests/predict/predictiveIndex.test.ts create mode 100644 keeper/tests/predict/snapshot.test.ts create mode 100644 keeper/tests/predict/solve.test.ts create mode 100644 keeper/tests/runtime/healthcheck.test.ts create mode 100644 keeper/tests/runtime/scheduler.test.ts create mode 100644 keeper/tests/tx/liquidate.test.ts create mode 100644 keeper/tests/venues/futures-marketid.test.ts create mode 100644 keeper/tests/venues/futures.test.ts create mode 100644 keeper/tests/venues/perps.test.ts create mode 100644 keeper/tsconfig.json diff --git a/keeper/.dockerignore b/keeper/.dockerignore new file mode 100644 index 0000000..0a5f8dd --- /dev/null +++ b/keeper/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.* +tests +*.log diff --git a/keeper/.gitignore b/keeper/.gitignore new file mode 100644 index 0000000..aa2dcd5 --- /dev/null +++ b/keeper/.gitignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.* +*.log +src/abi/*.json diff --git a/keeper/Dockerfile b/keeper/Dockerfile new file mode 100644 index 0000000..0621f9a --- /dev/null +++ b/keeper/Dockerfile @@ -0,0 +1,25 @@ +# ── Install deps ────────────────────────────────────────────────────────────── +FROM node:24-alpine AS deps + +WORKDIR /app + +RUN corepack enable + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +# ── Runtime ─────────────────────────────────────────────────────────────────── +FROM node:24-alpine + +ENV NODE_ENV=production + +WORKDIR /app + +COPY --from=deps /app/node_modules node_modules/ +COPY package.json tsconfig.json ./ +COPY src/ src/ + +RUN addgroup -S keeper && adduser -S keeper -G keeper +USER keeper + +CMD ["node", "--import=amaro/strip --conditions=typescript", "src/index.ts"] diff --git a/keeper/README.md b/keeper/README.md new file mode 100644 index 0000000..b3110f2 --- /dev/null +++ b/keeper/README.md @@ -0,0 +1,244 @@ +# collateral-margin keeper + +Single long-running off-chain coordinator that monitors the shared +`CollateralVault` and force-closes underwater Perps and Futures accounts via the +permissionless `liquidate*` entry points landed in Phase 0 of the unified +margin keeper plan. + +> **Status: implemented.** All Phase 1 and Phase 2 modules are wired and +> covered by `node:test` unit suites (run `pnpm test`). The keeper boots, +> tracks participants, runs the orders-then-positions plan and surfaces +> alerts. See `unified_margin_keeper_d6f69493.plan.md` for the full plan. + +## Why one worker + +| Concern | Why a single worker | +| -------------------- | ------------------------------------------------------------- | +| Shared vault | Both venues spend the same collateral; one signer avoids races | +| Shared margin engine | `computePortfolioMM` is portfolio-wide → cross-venue ordering matters | +| Strict orders-first | Cross-venue plan composes `liquidateOrders` → `liquidatePosition` atomically | +| One alert pipeline | Same vault → same human-facing alerts | + +## How it runs (per liquidation) + +``` + ┌──────────────────────┐ +events ──────►│ ParticipantTracker │── onAdded / onChanged ─┐ + └──────────┬───────────┘ │ + │ ▼ + │ ┌──────────────────────────────┐ + │ │ PredictiveCoordinator │ + │ │ · readAccountSnapshot │ + │ │ · solveLiquidationThresholds│ + │ │ · index.upsert(P_down/P_up) │ + │ └────────────┬─────────────────┘ + │ │ + ┌────────────────┘ │ index.crossings(prev,next) + ▼ ▲ +┌──────────────────────┐ │ AnswerUpdated +│ Scheduler │── alerts ──► Notifier ┌────────┴───────────┐ +│ · runSweep (60 s) │ (warn / │ PriceFeed │── reads ──► HashpriceUSDC +│ safety net only │ critical) │ (BTC/USDC events) │ +└──────────┬───────────┘ └────────────────────┘ + │ upsert(health) + ▼ +┌────────────────────────┐ pop() ┌───────────────────────────────────┐ +│ CoordinatorQueue │──────►│ Planner.run(user) │ +│ (mmSurplus ASC, │ │ 1. snapshot health │ +│ underwater only) │ │ 2. liquidateOrders × venues │ +└────────────────────────┘ │ 3. rank positions across venues │ + │ 4. liquidatePosition (worst) │ + │ 5. recheck, loop on race │ + └───────────────────────────────────┘ +``` + +The **PredictiveCoordinator** is the hot path: it watches BTC/USDC for +`AnswerUpdated`, re-reads the aggregated `HashpriceUSDC.latestRoundData`, +and uses pre-solved per-user liquidation prices to push exactly the +crossed users into the queue. The on-chain `mmSurplus` predicate stays the +source of truth — the planner re-reads it before any tx, so model drift +can only cause a spurious queue insert (caught instantly), never a +spurious liquidation. + +The **Scheduler** sweep is now the safety net: it covers funding accrual, +futures `pricePerDay` decay, and any model drift the predictor can't +capture exactly. Default cadence dropped from 10 s to 60 s. + +`Planner` calls into per-venue `Venue` adapters +(`src/venues/{perps,futures}.ts`). Each adapter encapsulates calldata, +multicall reads, gas estimation and decoding the recoverable reverts +(`OrdersStillOpen`, `NotLiquidatable`, …). Adding options later means +implementing one more adapter — the planner does not change. + +## Module layout + +``` +src/ + index.ts # Entry point — wires every module + graceful shutdown + config.ts # Env-driven config (LIQUIDATOR_PRIVATE_KEY, addresses, …) + chain.ts # Shared viem PublicClient + WalletClient + signer + abi/ # Generated ABI bundles, kept in sync via scripts/sync-abis.ts + pme/ + health.ts # readAccountHealthBatch via PME multicall (balance/IM/MM) + oracle/ + abi.ts # Minimal AggregatorV3 ABI (AnswerUpdated, latestRoundData, decimals) + priceFeed.ts # BTC/USDC subscription + HashpriceUSDC current-price reads + predict/ + types.ts # AccountSnapshot, MMParams, PriceThresholds + snapshot.ts # One-shot multicall: balance + perp/futures position state + PME shocks + mm.ts # Pure: mmRequired(snap, P), mmSurplus(snap, P), imRequired/imSurplus + solve.ts # Closed-form bisection: { liqDown, liqUp } per snapshot + predictiveIndex.ts # Sorted threshold index (down ASC, up ASC) with O(log) crossings + coordinator.ts # PriceFeed + tracker → index → CoordinatorQueue + executor.kick + discovery/ + tracker.ts # Event-driven participant set + one-shot startup backfill + webhook.ts # Optional Goldsky webhook ingester (Bearer-token auth) + venues/ + types.ts # Venue interface (multi-market aware: perps, futures, options) + perps.ts # Perps adapter (HashPowerPerpsDEX) + futures.ts # Futures adapter (deliveryAt → marketId) + coordinator/ + queue.ts # mmSurplus-ordered cross-account priority queue + planner.ts # Per-account orders → positions liquidation plan + executor.ts # Pulls from queue, runs planner with bounded concurrency + alert/ + notifier.ts # Shared dedup'd webhook notifier (warn → critical promotion) + tx/ + liquidate.ts # Shared simulate → send → parse-fee + revert-decoding helper + runtime/ + scheduler.ts # Periodic safety-net sweep over the tracker's user set + healthcheck.ts # GET /health for k8s/ECS liveness probes + +scripts/ + sync-abis.ts # Copies sibling-package ABIs into src/abi/ + +tests/ + coordinator/, venues/, pme/, alert/, discovery/, runtime/ # node:test suites +``` + +## Local dev + +```bash +# 1. Compile the source contracts so ABIs exist on disk. +pnpm -C ../../perps/contracts build +pnpm -C ../../futures-marketplace/contracts build +pnpm -C ../../collateral-margin/contracts build + +# 2. Pull the ABIs into src/abi/. +pnpm sync-abis + +# 3. Type-check + run the unit suite. +pnpm typecheck +pnpm test + +# 4. Run the keeper against a local node (see Config below). +pnpm dev:dry # dry-run — log planned actions but don't broadcast +pnpm dev # broadcast — real liquidations +``` + +## Config (env vars) + +See `src/config.ts` for the authoritative shape. The minimum-viable set: + +| Var | Required | Purpose | +| ------------------------------ | -------- | -------------------------------------- | +| `NETWORK` | yes | Chain label (e.g. `arbitrum-sepolia`) | +| `ETH_NODE_ADDRESS` | yes | RPC URL | +| `LIQUIDATOR_PRIVATE_KEY` | yes | Signer (single key for both venues) | +| `VAULT_ADDRESS` | yes | Shared CollateralVault | +| `PERPS_ADDRESS` | yes | HashPowerPerpsDEX | +| `FUTURES_ADDRESS` | yes | Futures | +| `PME_ADDRESS` | yes | PortfolioMarginEngine | +| `HASHPRICE_USDC_ADDRESS` | yes | HashpriceUSD aggregator (current spot) | +| `BTC_USDC_FEED_ADDRESS` | yes | Chainlink BTC/USDC AggregatorProxy (event source) | +| `PRICE_MOVE_TRIGGER_BPS` | no | Skip ticks below this fractional move (default `1`) | +| `DISCOVERY_MODE` | no | `events` (default) \| `webhook` \| `both` | +| `BACKFILL_FROM_BLOCK` | no | Block to start the one-shot startup backfill from (vault/perps/futures discovery events). Unset = forward-only — only safe with webhook discovery or a previously-warm tracker. | +| `BACKFILL_CHUNK_SIZE` | no | Per-`getLogs` page size for backfill. Default `10000` (most public RPC limit). | +| `DRY_RUN` | no | `true` to skip on-chain broadcasts | +| `ALERT_WEBHOOK_URL` | no | Slack/Discord/PagerDuty endpoint | +| `ALERT_DEDUPE_MS` | no | Dedupe window per (severity, user, market). Default `300_000` | +| `ALERT_IM_WARN_UTIL` | no | IM utilization triggering warn alert. Default `0.85` | +| `ALERT_IM_CRITICAL_UTIL` | no | IM utilization triggering critical alert. Default `0.95` | +| `WEBHOOK_PORT` | no | Goldsky ingestion port. Default `3001` | +| `WEBHOOK_SECRET` | no | `Authorization: Bearer ` shared secret | +| `COORDINATOR_MAX_CONCURRENT` | no | Concurrent plans. Default `1` (safe) | +| `COORDINATOR_CONFIRMATION_BLOCKS` | no | Block confirmations after each tx. Default `1` | +| `KEEPER_MIN_PROFIT_MARGIN` | no | Bail on plans that would net ≤ this in token decimals. Default `0` | +| `SWEEP_INTERVAL_MS` | no | Periodic safety-net sweep cadence (predictor handles the hot path). Default `60_000` | +| `HEALTH_PORT` | no | `GET /health` port. Default `3000` | +| `LOG_LEVEL` | no | pino level. Default `info` | + +## Dry run + +`DRY_RUN=true` (or `pnpm dev:dry`) skips every `writeContract` and instead +logs the request that would have been broadcast — discovery, ranking, +simulate-revert decoding and alerting all run as in production. This is the +pre-cutover validation step: point dry-run at the production RPC for a few +hours and grep the logs for `[dryRun] would send liquidate tx` to confirm +the keeper would have triggered exactly when the legacy systems did. + +## Cutover plan + +This package replaces both `futures-marketplace/margin-call/` (Lambda) and +`perps/keeper/` (single-venue keeper). The contracts in Phase 0 added +permissionless `liquidate*` entry points alongside the legacy paths so +cutover is staged: + +1. **Deploy** with `DRY_RUN=true` against production RPC. Verify alert + webhook + healthcheck. Compare planned actions against the live Lambda / + keeper logs for at least one liquidation cycle. +2. **Promote**: flip `DRY_RUN=false`. Leave the legacy systems running for a + day as a fallback — the contracts dedupe (you cannot liquidate the same + underwater account twice). +3. **Decommission** the legacy `margin-call` Lambda and `perps/keeper` + service. Re-balance alert routing to point only at this keeper. +4. **Cleanup** (separate PR): the futures contract's `marginCall` (validator- + only) entry point was preserved during Phase 0b for backward + compatibility. Once this keeper owns production traffic, that path can be + removed in a follow-up upgrade — see the plan's Phase 4. + +## Test surface + +``` +$ pnpm test +… +ℹ tests 144 +ℹ pass 144 +ℹ fail 0 +``` + +Suites cover: + +- `pme/health` — multicall batching + `imUtilization` precision +- `venues/perps` — long/short PnL math, `PERPS_MARKET_ID` sentinel, position id +- `venues/futures` — buyer/seller PnL, `deliveryAt` → marketId, `deliveryDurationDays` caching +- `coordinator/queue` — BigInt-safe ordering, `upsert` re-ranking, snapshot semantics +- `coordinator/planner` — orders-leg, position ranking, `OrdersStillOpen`-replay, bad-debt +- `alert/notifier` — dedupe window, severity promotion, ordering, retry-on-failure +- `discovery/tracker` — checksum dedupe, `onAdded` / `onChanged` listeners, startup backfill +- `discovery/webhook` — payload extraction across `data` / `records` / array shapes +- `runtime/scheduler` — alert ladder thresholds, queue upsert + executor kick wiring +- `oracle/priceFeed` — rebase to token decimals, dispatch, no-op on unchanged answer +- `predict/mm` — net delta, stress, perp/futures unrealized loss, mm/im surplus +- `predict/solve` — long/short downside & upside thresholds, drag from orderMargin/funding +- `predict/predictiveIndex` — upsert/invalidate, sorted crossings on rise & drop +- `predict/snapshot` — multicall shape, funding-clamping, futures buyer/seller hydration +- `predict/coordinator` — end-to-end (priceFeed → solver → queue), drift safety net, bps gate +- `tx/liquidate` — exposed via venue tests (revert decoding round-trip) + +## Predictive layer notes + +Currently in scope: +- Pure-delta MM math (perps + futures). Closed-form bisection over kinks + is < 100 µs per user; an N-user reindex on a price tick is dominated by + the multicall RPC, not the solver. +- Single price axis (HashpriceUSDC) — both venues read the same upstream. +- Downside *and* upside crossings (covers leveraged longs and shorts). + +Currently out of scope (deferred — periodic sweep covers them): +- Options Greeks (γ, ν stress terms — PME is delta-only until options engine registered). +- Predictive IM warn / critical alerts (warn / critical still fire from sweep). +- HashpriceBTC `HashpriceUpdated` subscription (10-min cadence; sweep covers it). +- Funding-rate-aware re-prediction at next funding tick. +- Futures `pricePerDay` time-decay scheduling. diff --git a/keeper/package.json b/keeper/package.json new file mode 100644 index 0000000..013794b --- /dev/null +++ b/keeper/package.json @@ -0,0 +1,34 @@ +{ + "name": "collateral-margin-keeper", + "version": "0.1.0", + "type": "module", + "private": true, + "engines": { + "node": ">=22.6.0" + }, + "scripts": { + "postinstall": "node ./scripts/fix-placeholder-manifests.ts", + "node": "node --import=amaro/strip", + "dev": "pnpm node --env-file=.env src/index.ts | pino-pretty", + "dev:dry": "DRY_RUN=true pnpm node --env-file=.env src/index.ts | pino-pretty", + "start": "pnpm node --env-file=.env src/index.ts", + "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test:watch": "pnpm node --test --watch --test-concurrency=1 'tests/**/*.test.ts'", + "typecheck": "tsc --noEmit", + "docker": "docker build -t collateral-margin-keeper ." + }, + "dependencies": { + "amaro": "^1.1.9", + "collateral-margin": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", + "derivatives-marketplace": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", + "futures-marketplace": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", + "pino": "^10.3.1", + "viem": "^2.48.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "pino-pretty": "^13.1.3" + }, + "packageManager": "pnpm@11.1.1" +} \ No newline at end of file diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml new file mode 100644 index 0000000..cc9c610 --- /dev/null +++ b/keeper/pnpm-lock.yaml @@ -0,0 +1,477 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + amaro: + specifier: ^1.1.9 + version: 1.1.9 + collateral-margin: + specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi + derivatives-marketplace: + specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi + futures-marketplace: + specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi + pino: + specifier: ^10.3.1 + version: 10.3.1 + viem: + specifier: ^2.48.8 + version: 2.48.11 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260511.1 + version: 7.0.0-dev.20260511.1 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-zIe31OYgBvkgTIQEwJtKim6SYyuVTkr+9fK/87hVwKN15X3Ikjeh0C0g2W/Vl4rXeMvy95wBGDN1jpW11DIvgg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-YbmCQXGYkDChGFG7hXJzIgmRjtU1kE5VK/+k322nGnbq4ePqSjS3dS0+ehPATmvfO1XjCDfh3ekED+AtmWk6aQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-02b45lpPmYf125PvcnK67WW93N55qwKmtInwfVefV997S17Ib3h6hlCW4e24BDhNsGRCSLhPA4Lu7ZvTq5pLkw==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-e+TweaVJFaM96tV1UM1kRfk2y8QBkZtz7+0wcxrDGmyJz3IIRUlg1btocaBkhsmVtQPXMr37RutBBMgpl3vgUg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-zgkoGiCpOrly5h8ghcuu6ZNSfrnRqtHoCq584Q92+s4D/j1MU3oKkGPvmkezp5Mj2v7ffR9AjU+lWRDkrfm6eA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-SUm7iVYzKaflol+QwH0Ny5jZtco6PJduI+h/TEg0sgBJzVBa+9RN4I9+Xu9v+EJ1bci3XI7835IRdSP36lCgCw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260511.1': + resolution: {integrity: sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA==} + engines: {node: '>=16.20.0'} + hasBin: true + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + amaro@1.1.9: + resolution: {integrity: sha512-Qx5+iHi3mKWz95XNx/WPFl8yRMZEGNoRZDaOkoej72kxAo20FbDVx7jALcvyOn/N3+h+GboKip49yba7xqLlKA==} + engines: {node: '>=22'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79} + version: 0.0.0 + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + derivatives-marketplace@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8} + version: 0.0.0 + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + futures-marketplace@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5} + version: 0.0.0 + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + thread-stream@4.1.0: + resolution: {integrity: sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==} + engines: {node: '>=20'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + viem@2.48.11: + resolution: {integrity: sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@pinojs/redact@0.4.0': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260511.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260511.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260511.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260511.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260511.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260511.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260511.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260511.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260511.1 + + abitype@1.2.3: {} + + amaro@1.1.9: {} + + atomic-sleep@1.0.0: {} + + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi: {} + + colorette@2.0.20: {} + + dateformat@4.6.3: {} + + derivatives-marketplace@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + eventemitter3@5.0.1: {} + + fast-copy@4.0.3: {} + + fast-safe-stringify@2.1.1: {} + + futures-marketplace@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi: {} + + help-me@5.0.0: {} + + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + + joycon@3.1.1: {} + + minimist@1.2.8: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + ox@0.14.20: + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3 + eventemitter3: 5.0.1 + transitivePeerDependencies: + - zod + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.1.0 + + process-warning@5.0.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + strip-json-comments@5.0.3: {} + + thread-stream@4.1.0: + dependencies: + real-require: 1.0.0 + + undici-types@6.21.0: {} + + viem@2.48.11: + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3 + isows: 1.0.7(ws@8.18.3) + ox: 0.14.20 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + wrappy@1.0.2: {} + + ws@8.18.3: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml new file mode 100644 index 0000000..1aec1d9 --- /dev/null +++ b/keeper/pnpm-workspace.yaml @@ -0,0 +1 @@ +autoInstallPeers: false diff --git a/keeper/scripts/fix-placeholder-manifests.ts b/keeper/scripts/fix-placeholder-manifests.ts new file mode 100644 index 0000000..07c6ece --- /dev/null +++ b/keeper/scripts/fix-placeholder-manifests.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Postinstall fixup for folder-scoped git deps. + * + * `github:org/repo#branch&path:/sub/folder` deps where the sub-folder has + * no `package.json` are shipped by pnpm with a placeholder manifest: + * + * {"_pnpmPlaceholder":"This file was generated by pnpm. ..."} + * + * The placeholder lacks `"type"`, so Node treats the package as CJS and + * routes the bundled `.ts` files through its built-in type-stripper, which + * refuses to run on anything inside `node_modules`. Forcing ESM lets the + * `amaro/strip` loader transform them at runtime. + * + * The pnpm `readPackage` hook only mutates the in-memory manifest used for + * resolution and never writes back to disk, so we patch the placeholders + * here instead. Idempotent and safe to re-run. + * + * The script walks the on-disk pnpm store layout (`node_modules` plus + * `node_modules/.pnpm/@/node_modules/`) and patches every + * placeholder it finds, so newly added folder-scoped deps are covered + * automatically without updating an allowlist. + */ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface PlaceholderManifest { + _pnpmPlaceholder?: string; + type?: string; + [key: string]: unknown; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const nodeModules = resolve(here, "..", "node_modules"); + +async function listDir(path: string) { + try { + return await readdir(path, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } +} + +/** + * Yields every `` directory we want to inspect: + * - direct deps under `node_modules/` and `node_modules/@scope/` + * - hoisted/virtual copies under `node_modules/.pnpm//node_modules/` + * (and the same with scopes) + */ +async function* iterPackageRoots(root: string): AsyncGenerator { + for (const entry of await listDir(root)) { + if (!entry.isDirectory()) continue; + if (entry.name === ".bin") continue; + + if (entry.name === ".pnpm") { + const pnpmRoot = resolve(root, ".pnpm"); + for (const pkgId of await listDir(pnpmRoot)) { + if (!pkgId.isDirectory()) continue; + yield* iterPackageRoots(resolve(pnpmRoot, pkgId.name, "node_modules")); + } + continue; + } + + if (entry.name.startsWith("@")) { + const scopeDir = resolve(root, entry.name); + for (const scoped of await listDir(scopeDir)) { + if (!scoped.isDirectory()) continue; + yield resolve(scopeDir, scoped.name); + } + continue; + } + + yield resolve(root, entry.name); + } +} + +async function patchPlaceholder(pkgRoot: string): Promise { + const manifestPath = resolve(pkgRoot, "package.json"); + let raw: string; + try { + raw = await readFile(manifestPath, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } + + let manifest: PlaceholderManifest; + try { + manifest = JSON.parse(raw) as PlaceholderManifest; + } catch { + return false; + } + + if (manifest._pnpmPlaceholder === undefined) return false; + if (manifest.type === "module") return false; + + manifest.type = "module"; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + return true; +} + +let patched = 0; +for await (const pkgRoot of iterPackageRoots(nodeModules)) { + if (await patchPlaceholder(pkgRoot)) patched += 1; +} + +if (patched > 0) { + console.log(`[fix-placeholder-manifests] patched ${patched} placeholder package.json file(s)`); +} diff --git a/keeper/src/alert/notifier.ts b/keeper/src/alert/notifier.ts new file mode 100644 index 0000000..1d43d80 --- /dev/null +++ b/keeper/src/alert/notifier.ts @@ -0,0 +1,185 @@ +import type pino from "pino"; +import type { Config } from "../config.ts"; +import type { AccountHealth } from "../pme/health.ts"; +import type { MarketId } from "../venues/types.ts"; + +/** + * Shared notifier for both venues. Same vault → same human-facing alerts. + * + * Two responsibilities: + * 1. Dedupe: an account that just fired a critical alert should not refire + * every sweep tick. Configurable via `alerts.dedupeMs`. Dedup key is + * `(severity, user, marketLabel ?? "*")` — same severity for the same + * account/market suppresses; a *promotion* from warn → critical bypasses + * the dedupe window (we always tell on-call when things get worse). + * 2. Drain order: when many alerts are pending, send them out + * most-underwater-first (matching the coordinator queue) so on-call + * sees the worst first. + * + * The notifier is intentionally non-blocking on the planner's hot path: + * `enqueue` is sync and fast; `drain` runs on the runtime scheduler and + * sequentially POSTs everything that's pending. + */ +export class Notifier { + /** dedupKey → unix-ms of last successful send. */ + private readonly lastSentAt = new Map(); + /** Pending alerts buffered until `drain()` runs. */ + private pending: Alert[] = []; + private readonly config: Config; + private readonly logger: pino.Logger; + private readonly poster: WebhookPoster; + private readonly now: () => number; + + constructor( + config: Config, + logger: pino.Logger, + options: { poster?: WebhookPoster; now?: () => number } = {}, + ) { + this.config = config; + this.logger = logger; + this.poster = options.poster ?? defaultWebhookPoster; + this.now = options.now ?? (() => Date.now()); + } + + /** + * Push an alert. May be dropped immediately if a same-severity-same-target + * alert was sent within `alerts.dedupeMs`. Severity *promotions* (warn → + * critical) always pass through — getting worse should always page. + */ + enqueue(alert: Alert): void { + const key = dedupKey(alert); + const last = this.lastSentAt.get(key); + const isPromotion = + alert.severity === "critical" && + this.lastSentAt.has(warnKey(alert)) && + !this.lastSentAt.has(criticalKey(alert)); + + if (!isPromotion && last !== undefined && this.now() - last < this.config.alerts.dedupeMs) { + this.logger.debug({ user: alert.user, key }, "alert deduped"); + return; + } + this.pending.push(alert); + } + + /** + * Drains the pending queue in insertion order. The scheduler walks tracked + * users in a stable order, so insertion order is "roughly worst-first + * across the sweep" with no extra work. Records `lastSentAt` only on + * success — failed sends stay eligible for retry on the next drain. + */ + async drain(): Promise { + if (!this.config.alerts.webhookUrl) { + // Webhook disabled — clear the buffer so it can't grow unbounded. + if (this.pending.length > 0) { + this.logger.warn( + { count: this.pending.length }, + "alerts pending but ALERT_WEBHOOK_URL is unset — dropping", + ); + this.pending = []; + } + return; + } + if (this.pending.length === 0) return; + + const batch = this.pending; + this.pending = []; + + for (const alert of batch) { + try { + await this.poster(this.config.alerts.webhookUrl, formatPayload(alert)); + this.lastSentAt.set(dedupKey(alert), this.now()); + this.logger.info( + { user: alert.user, severity: alert.severity, market: alert.market?.marketLabel }, + "alert sent", + ); + } catch (err) { + this.logger.error( + { user: alert.user, severity: alert.severity, err }, + "alert send failed — will retry on next drain", + ); + // Re-buffer the failed alert so we don't lose it. Place at the head + // so it's still considered urgent next drain. + this.pending.unshift(alert); + } + } + } + + /** Visible for tests. */ + pendingCount(): number { + return this.pending.length; + } +} + +/** `(severity, user, marketLabel ?? "*")` — see Notifier docstring. */ +function dedupKey(alert: Alert): string { + return `${alert.severity}|${alert.user}|${alert.market?.marketLabel ?? "*"}`; +} + +function warnKey(alert: Alert): string { + return `warn|${alert.user}|${alert.market?.marketLabel ?? "*"}`; +} + +function criticalKey(alert: Alert): string { + return `critical|${alert.user}|${alert.market?.marketLabel ?? "*"}`; +} + +/** + * Webhook payload shape. Kept generic enough to render correctly in Slack / + * Discord (which both honour `text` + `blocks`-equivalent attachments) — the + * downstream channel can reformat as needed. + */ +function formatPayload(alert: Alert) { + const market = alert.market !== undefined ? ` (${alert.market.marketLabel})` : ""; + return { + text: `[${alert.severity.toUpperCase()}] ${alert.user}${market}: ${alert.reason}`, + severity: alert.severity, + user: alert.user, + market: alert.market, + health: { + balance: alert.health.balance.toString(), + imRequired: alert.health.imRequired.toString(), + mmRequired: alert.health.mmRequired.toString(), + mmSurplus: alert.health.mmSurplus.toString(), + imUtilization: alert.health.imUtilization, + }, + reason: alert.reason, + }; +} + +export type AlertSeverity = "warn" | "critical"; + +export interface MarketAlertContext { + venue: "perps" | "futures" | "options"; + marketId: MarketId; + marketLabel: string; +} + +export interface Alert { + severity: AlertSeverity; + user: AccountHealth["user"]; + health: AccountHealth; + /** Optional venue/market context — present when the alert is venue-scoped. */ + market?: MarketAlertContext; + reason: string; +} + +/** + * Pluggable webhook poster. The default implementation uses `fetch` against + * `config.alerts.webhookUrl`; tests inject a stub to capture payloads + * without touching the network. + */ +export type WebhookPoster = (url: string, payload: unknown) => Promise; + +const defaultWebhookPoster: WebhookPoster = async (url, payload) => { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`alert webhook ${url} returned ${res.status} ${res.statusText}`); + } +}; + +/** Exposed for unit tests. */ +export const __testing = { dedupKey, formatPayload }; diff --git a/keeper/src/chain.ts b/keeper/src/chain.ts new file mode 100644 index 0000000..88a8004 --- /dev/null +++ b/keeper/src/chain.ts @@ -0,0 +1,31 @@ +import { + createPublicClient, + createWalletClient, + http, + type Account, + type PublicClient, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import type { Config } from "./config.ts"; + +export interface Chain { + publicClient: PublicClient; + walletClient: WalletClient; + account: Account; +} + +/** + * Builds the shared viem clients used by every module in the keeper. + * The PublicClient is the one source of RPC reads (multicalls, event watchers, + * receipts); the WalletClient is the single signer that broadcasts both perps + * and futures liquidations — there is no separate validator key any more. + */ +export function createChain(config: Config): Chain { + const transport = http(config.chain.rpcUrl); + const publicClient = createPublicClient({ transport }); + const account = privateKeyToAccount(config.keeper.privateKey); + const walletClient = createWalletClient({ account, transport }); + + return { publicClient, walletClient, account }; +} diff --git a/keeper/src/config.ts b/keeper/src/config.ts new file mode 100644 index 0000000..c33114f --- /dev/null +++ b/keeper/src/config.ts @@ -0,0 +1,218 @@ +import { getAddress, isAddress, isHex } from "viem"; +import type { Address, Hex } from "viem"; +import type pino from "pino"; + +/** + * Configuration for the unified margin keeper. + * + * Lives entirely in environment variables — same pattern as the legacy + * perps keeper and futures margin-call lambda so secret management / + * deployment templates can be reused unchanged. + * + * Keys grouped by responsibility, mirroring the module layout: + * - chain: RPC + network id (shared) + * - vault: shared CollateralVault address + * - perps: Perps DEX address + per-venue overrides + * - futures: Futures address + per-venue overrides + * - pme: PortfolioMarginEngine address + * - keeper: LIQUIDATOR_PRIVATE_KEY + tx behaviour + * - alerts: notification webhook(s) + * - triggers: thresholds for IM / MM utilization alerts + * - coordinator: cross-account ordering + concurrency + * - runtime: healthcheck port, log level, dry-run, intervals + */ +export interface Config { + chain: { + network: string; + rpcUrl: string; + /** Optional: prefer to use Goldsky webhooks over RPC event subscriptions. */ + discoveryMode: "events" | "webhook" | "both"; + /** + * Block to start the one-shot historical backfill from on startup. We + * scan vault / perps / futures discovery events from this block up to + * the head, then hand off to the live `watchContractEvent` stream. + * Undefined disables backfill (forward-only mode — only safe if the + * webhook ingester or a long-running prior keeper has primed the set). + */ + backfillFromBlock?: bigint; + /** + * `getLogs` page size. Most public RPCs cap log ranges at 10k blocks, + * so we chunk. Lower this if your provider is stricter. + */ + backfillChunkSize: bigint; + }; + vault: { address: Address }; + perps: { + address: Address; + /** Optional fast pre-filter: only consider users above this notional ($USDC token decimals). */ + minNotional?: bigint; + }; + futures: { + address: Address; + /** Optional fast pre-filter (token decimals). */ + minNotional?: bigint; + }; + pme: { address: Address }; + oracle: { + /** + * HashpriceUSD aggregator (`AggregatorV3Interface`) — single source for the + * current hashprice in USDC. Both Perps and Futures contracts read from + * the same upstream feed, so this one address covers both venues. + */ + hashpriceUsdcAddress: Address; + /** + * Chainlink BTC/USDC `AggregatorProxy`. We subscribe to its `AnswerUpdated` + * event as the trigger for re-evaluating the predictive index — BTC/USDC + * dominates `HashpriceUSD = HashpriceBTC * BTC/USD` in update frequency + * (BTC blocks are ~10 min; BTC/USDC moves on Chainlink's deviation/heartbeat + * thresholds, much more often). + */ + btcUsdcFeedAddress: Address; + /** + * Minimum fractional price move (in basis points) before the predictive + * coordinator processes the new tick. Filters out micro-jitter that can't + * possibly cross any user's liquidation threshold. 0 = process every event. + */ + priceMoveTriggerBps: number; + }; + keeper: { + /** Single signer used for both perps and futures liquidations. */ + privateKey: Hex; + /** When true, log planned actions but don't broadcast transactions. */ + dryRun: boolean; + /** + * If a coordinated plan would yield less than this in fees minus gas + * estimate, skip it. Token decimals (USDC = 6). + */ + minProfitMargin: bigint; + }; + alerts: { + /** Slack/Discord/etc. webhook URL. Disabled if undefined. */ + webhookUrl?: string; + /** Once an account fires an alert, do not re-alert for this many ms. */ + dedupeMs: number; + /** IM utilization (imRequired / balance) above this triggers a warn alert. */ + imWarnUtilization: number; + /** Same, but a critical alert and ranks higher in the queue. */ + imCriticalUtilization: number; + }; + triggers: { + /** Webhook ingestion port. Only used when discoveryMode includes "webhook". */ + webhookPort: number; + /** Optional shared secret required by `Authorization: Bearer ` from Goldsky. */ + webhookSecret?: string; + }; + coordinator: { + /** Max accounts processed concurrently — 1 means strict serial coordination. */ + maxConcurrentAccounts: number; + /** Block-confirmation depth waited for before re-running planner on a target. */ + confirmationBlocks: number; + }; + runtime: { + /** Cadence of the periodic re-evaluation sweep in ms. Event-driven path is primary. */ + sweepIntervalMs: number; + healthPort: number; + logLevel: pino.Level; + }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function optionalBigInt(name: string): bigint | undefined { + const value = process.env[name]; + return value === undefined ? undefined : BigInt(value); +} + +/** + * Read an env var that must be a 0x-prefixed 20-byte EVM address. + * Returns the EIP-55 checksummed form so downstream comparisons / logs + * are consistent regardless of how operators capitalize the input. + */ +function requireAddress(name: string): Address { + const value = requireEnv(name); + if (!isAddress(value, { strict: false })) { + throw new Error(`Environment variable ${name} must be a valid EVM address, got "${value}"`); + } + return getAddress(value); +} + +/** + * Read an env var that must be a 0x-prefixed hex string of the given byte length + * (omit `bytes` to accept any length). Used for private keys and similar secrets. + */ +function requireHex(name: string, bytes?: number): Hex { + const value = requireEnv(name); + if (!isHex(value)) { + throw new Error(`Environment variable ${name} must be a 0x-prefixed hex string`); + } + if (bytes !== undefined && value.length !== 2 + bytes * 2) { + throw new Error(`Environment variable ${name} must be ${bytes} bytes (${2 + bytes * 2} chars), got ${value.length}`); + } + return value; +} + +export function loadConfig(): Config { + const discoveryMode = (process.env.DISCOVERY_MODE ?? "events") as Config["chain"]["discoveryMode"]; + if (!["events", "webhook", "both"].includes(discoveryMode)) { + throw new Error(`DISCOVERY_MODE must be one of events|webhook|both, got "${discoveryMode}"`); + } + + return { + chain: { + network: requireEnv("NETWORK"), + rpcUrl: requireEnv("ETH_NODE_ADDRESS"), + discoveryMode, + backfillFromBlock: optionalBigInt("BACKFILL_FROM_BLOCK"), + backfillChunkSize: BigInt(process.env.BACKFILL_CHUNK_SIZE ?? "10000"), + }, + vault: { address: requireAddress("VAULT_ADDRESS") }, + perps: { + address: requireAddress("PERPS_ADDRESS"), + minNotional: optionalBigInt("PERPS_MIN_NOTIONAL"), + }, + futures: { + address: requireAddress("FUTURES_ADDRESS"), + minNotional: optionalBigInt("FUTURES_MIN_NOTIONAL"), + }, + pme: { address: requireAddress("PME_ADDRESS") }, + oracle: { + hashpriceUsdcAddress: requireAddress("HASHPRICE_USDC_ADDRESS"), + btcUsdcFeedAddress: requireAddress("BTC_USDC_FEED_ADDRESS"), + priceMoveTriggerBps: Number(process.env.PRICE_MOVE_TRIGGER_BPS ?? "1"), + }, + keeper: { + privateKey: requireHex("LIQUIDATOR_PRIVATE_KEY", 32), + dryRun: process.env.DRY_RUN === "true", + minProfitMargin: BigInt(process.env.KEEPER_MIN_PROFIT_MARGIN ?? "0"), + }, + alerts: { + webhookUrl: process.env.ALERT_WEBHOOK_URL, + dedupeMs: Number(process.env.ALERT_DEDUPE_MS ?? "300000"), + imWarnUtilization: Number(process.env.ALERT_IM_WARN_UTIL ?? "0.85"), + imCriticalUtilization: Number(process.env.ALERT_IM_CRITICAL_UTIL ?? "0.95"), + }, + triggers: { + webhookPort: Number(process.env.WEBHOOK_PORT ?? "3001"), + webhookSecret: process.env.WEBHOOK_SECRET, + }, + coordinator: { + maxConcurrentAccounts: Number(process.env.COORDINATOR_MAX_CONCURRENT ?? "1"), + confirmationBlocks: Number(process.env.COORDINATOR_CONFIRMATION_BLOCKS ?? "1"), + }, + runtime: { + // Default 60s. The predictive coordinator drives the hot-path + // re-evaluation off price events; this sweep is now the safety net + // for things the predictor can't model exactly (funding accrual, + // futures `pricePerDay` decay, model drift). + sweepIntervalMs: Number(process.env.SWEEP_INTERVAL_MS ?? "60000"), + healthPort: Number(process.env.HEALTH_PORT ?? "3000"), + logLevel: (process.env.LOG_LEVEL as pino.Level) ?? "info", + }, + }; +} diff --git a/keeper/src/coordinator/executor.ts b/keeper/src/coordinator/executor.ts new file mode 100644 index 0000000..efe0a71 --- /dev/null +++ b/keeper/src/coordinator/executor.ts @@ -0,0 +1,166 @@ +import type pino from "pino"; +import type { Config } from "../config.ts"; +import type { CoordinatorQueue } from "./queue.ts"; +import type { Planner, PlanOutcome } from "./planner.ts"; + +/** + * Drives the planner. Pulls accounts from the queue (most-underwater first) + * and runs the per-account plan. + * + * Concurrency is configurable via `coordinator.maxConcurrentAccounts`. The + * default of 1 is the safe choice today: shared PME means concurrent plans + * for the same user are unsafe, and concurrent plans for different users + * could compete for the same vault state when one user's liquidation drains + * the insurance fund. Bumping `maxConcurrentAccounts` later requires a + * per-user lock; the executor enforces "one plan per user at a time" by + * tracking in-flight users in `inflight`, which holds even at concurrency 1. + * + * Lifecycle: + * - `start()` spawns the worker loop(s) and returns immediately. + * - The loop polls `queue.pop()`. When the queue drains it sleeps on the + * next `kick()` — events / sweeps wake it up. + * - `stop()` flips `running=false`. Outstanding plans finish; no new ones + * are picked up. + */ +export class CoordinatorExecutor { + private running = false; + private readonly inflight = new Set(); + private wakeUp: (() => void) | undefined; + private workers: Promise[] = []; + + // Explicit fields — Node's TypeScript strip-only mode does not support + // parameter properties (the `private readonly config: Config` shortcut). + private readonly config: Config; + private readonly queue: CoordinatorQueue; + private readonly planner: Planner; + private readonly logger: pino.Logger; + + constructor( + config: Config, + queue: CoordinatorQueue, + planner: Planner, + logger: pino.Logger, + ) { + this.config = config; + this.queue = queue; + this.planner = planner; + this.logger = logger; + } + + async start(): Promise { + if (this.running) { + this.logger.warn("CoordinatorExecutor.start: already running"); + return; + } + this.running = true; + const concurrency = Math.max(1, this.config.coordinator.maxConcurrentAccounts); + this.logger.info({ maxConcurrent: concurrency }, "CoordinatorExecutor.start"); + this.workers = Array.from({ length: concurrency }, (_, i) => this.workerLoop(i)); + await Promise.resolve(); + } + + /** + * Wake all idle workers. Called by the discovery layer after upserting an + * account into the queue, by the periodic sweep, and by the planner itself + * when it needs to re-queue a stalled account. + */ + kick(): void { + if (this.wakeUp !== undefined) { + this.wakeUp(); + this.wakeUp = undefined; + } + } + + async stop(): Promise { + this.running = false; + this.kick(); + await Promise.allSettled(this.workers); + this.workers = []; + this.logger.info("CoordinatorExecutor.stop: drained"); + } + + isRunning(): boolean { + return this.running; + } + + /** Visible for tests — count of in-flight users. */ + inflightCount(): number { + return this.inflight.size; + } + + private async workerLoop(workerId: number): Promise { + const log = this.logger.child({ workerId }); + log.debug("worker loop started"); + while (this.running) { + const next = this.popNonInflight(); + if (next === undefined) { + // Queue empty (or every entry is already being worked) — wait for kick. + await this.waitForKick(); + continue; + } + this.inflight.add(next.user); + try { + const outcome = await this.planner.run(next.user); + this.handleOutcome(next.user, outcome, log); + } catch (err) { + // Hard failure (RPC down, unrecoverable revert). Log and re-queue + // with the stale snapshot so the next sweep refreshes health. + log.error({ user: next.user, err }, "Planner.run threw — re-queueing"); + this.queue.upsert(next); + } finally { + this.inflight.delete(next.user); + } + } + log.debug("worker loop exited"); + } + + /** + * Pops the head of the queue, but skips entries already in flight on + * another worker. We re-queue any skipped entries so they aren't lost. + * + * Returns the head entry, or undefined when nothing is workable. + */ + private popNonInflight(): ReturnType { + const skipped: NonNullable>[] = []; + let next: ReturnType = this.queue.pop(); + while (next !== undefined && this.inflight.has(next.user)) { + skipped.push(next); + next = this.queue.pop(); + } + for (const s of skipped) this.queue.upsert(s); + return next; + } + + /** Resolves on the next `kick()` or on `stop()`. */ + private waitForKick(): Promise { + return new Promise((resolve) => { + const prev = this.wakeUp; + this.wakeUp = () => { + if (prev !== undefined) prev(); + resolve(); + }; + }); + } + + private handleOutcome(user: string, outcome: PlanOutcome, log: pino.Logger): void { + switch (outcome.kind) { + case "healthy": + case "liquidated": + log.info({ user, outcome }, "Plan complete"); + return; + case "stalled": + // Re-queue with the latest mmSurplus so the next sweep / event + // promotes it back into priority order. + log.warn({ user, outcome }, "Plan stalled — re-queueing"); + // Caller (discovery layer) will refresh the health snapshot before + // re-upserting; if we re-upsert here we'd carry a stale snapshot. + // Just log and rely on the periodic sweep. + return; + case "badDebt": + // Critical alert path is owned by the notifier — we surface the + // outcome via logs and let the alert layer subscribe to those. + log.error({ user, outcome }, "BadDebt: insurance fund must absorb residual"); + return; + } + } +} diff --git a/keeper/src/coordinator/planner.ts b/keeper/src/coordinator/planner.ts new file mode 100644 index 0000000..98840ef --- /dev/null +++ b/keeper/src/coordinator/planner.ts @@ -0,0 +1,309 @@ +import type { Address, Hex } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Venue, VenuePosition } from "../venues/types.ts"; +import type { Config } from "../config.ts"; +import type { AccountHealth } from "../pme/health.ts"; +import { readAccountHealthBatch } from "../pme/health.ts"; + +/** What happened at the end of a single `Planner.run(user)` call. */ +export type PlanOutcome = + | { kind: "healthy"; mmSurplus: bigint } + | { + kind: "liquidated"; + mmSurplus: bigint; + feeEarned: bigint; + positionsClosed: number; + ordersClosed: number; + } + | { kind: "badDebt"; mmSurplus: bigint; feeEarned: bigint } + | { kind: "stalled"; reason: string; mmSurplus: bigint }; + +/** Internal step result — captured for telemetry / tests. */ +interface StepReport { + kind: "ordersLeg" | "positionLeg"; + venue: Venue["name"]; + feeEarned: bigint; + ordersClosed?: number; + positionId?: Hex; + skipped?: string; +} + +/** + * Per-account coordinated liquidation plan. + * + * Algorithm (mirrors the Mermaid flowchart in the unified-margin-keeper plan): + * + * 1. Snapshot orders + positions across ALL venues for `user`, plus + * `readAccountHealthBatch([user])`. + * 2. If `mmSurplus >= 0`: account is healthy — emit `done`. + * 3. Else: call `liquidateOrders` on every venue that has open orders. + * Re-snapshot health. + * 4. If still unhealthy: pick the most-underwater single position across + * all venues (max `unrealizedLoss`, tiebreak on `notional`) and call + * `liquidatePosition` on that venue. The on-chain `OrdersStillOpen` + * revert is treated as a recoverable race — re-run step 3 then retry. + * Re-snapshot health. + * 5. Repeat step 4 until healthy OR no positions remain. If no positions + * remain and the account is still unhealthy, emit a `BadDebt` log and + * a critical alert (the insurance fund must absorb the residual). + * + * The planner is purely orchestration — venues encapsulate calldata, + * Multicall3 batching, gas estimation, and the unprofitable / not-liquidatable + * skip predicates. + */ +export class Planner { + /** + * Hard cap on the position-leg loop. Each iteration closes at least one + * position OR retries an `ordersLeg` after an `OrdersStillOpen` race — + * looping forever shouldn't be possible, but this is a defense-in-depth + * cap so a venue bug can't pin the executor on one user. Set generously: + * 16 iterations × ~50 positions per venue ≈ 800 closures, far above any + * realistic single-user portfolio. + */ + private static readonly MAX_POSITION_ITERATIONS = 16; + + // Explicit fields — Node's TypeScript strip-only mode does not support + // parameter properties (the `private readonly chain: Chain` shortcut). + private readonly chain: Chain; + private readonly config: Config; + private readonly venues: readonly Venue[]; + private readonly logger: pino.Logger; + + constructor( + chain: Chain, + config: Config, + venues: readonly Venue[], + logger: pino.Logger, + ) { + this.chain = chain; + this.config = config; + this.venues = venues; + this.logger = logger; + } + + async run(user: Address): Promise { + const log = this.logger.child({ user }); + const reports: StepReport[] = []; + let totalFee = 0n; + let ordersClosed = 0; + let positionsClosed = 0; + + // Step 1+2: initial snapshot. Cheap exit if the account is already healthy + // — this is the common case for re-evaluation triggers. + let health = await this.readHealth(user); + if (health.mmSurplus >= 0n) { + log.debug({ mmSurplus: health.mmSurplus }, "Account healthy on entry — no plan to run"); + return { kind: "healthy", mmSurplus: health.mmSurplus }; + } + + log.info( + { mmSurplus: health.mmSurplus }, + "Planner.run: account underwater, running coordinated plan", + ); + + // Step 3: orders-leg across every venue. Each venue's `liquidateOrders` + // is permissionless and natively handles "no open orders" via the + // `notLiquidatable` skip — we don't need a per-venue read first. + const ordersLegReports = await this.runOrdersLeg(user, log); + reports.push(...ordersLegReports); + for (const r of ordersLegReports) { + totalFee += r.feeEarned; + ordersClosed += r.ordersClosed ?? 0; + } + + health = await this.readHealth(user); + if (health.mmSurplus >= 0n) { + log.info( + { mmSurplus: health.mmSurplus, totalFee, ordersClosed }, + "Account healthy after orders-leg — done", + ); + return { + kind: "liquidated", + mmSurplus: health.mmSurplus, + feeEarned: totalFee, + positionsClosed: 0, + ordersClosed, + }; + } + + // Step 4–5: position loop, one position at a time, picking the worst + // across all venues. We re-snapshot health after every closure since + // closing one position can flip the account healthy or change the + // ranking of the remaining positions. + for (let iter = 0; iter < Planner.MAX_POSITION_ITERATIONS; iter++) { + const ranked = await this.rankPositions(user); + if (ranked.length === 0) { + // No positions left to close but still unhealthy → bad debt. + log.error( + { mmSurplus: health.mmSurplus, totalFee, positionsClosed, ordersClosed }, + "BadDebt: no positions remain but account still under MM", + ); + return { kind: "badDebt", mmSurplus: health.mmSurplus, feeEarned: totalFee }; + } + + const worst = ranked[0]; + log.info( + { + venue: worst.venue.name, + marketLabel: worst.venue.marketLabel(worst.position.marketId), + unrealizedLoss: worst.position.unrealizedLoss, + notional: worst.position.notional, + }, + "Position-leg: liquidating worst position", + ); + const result = await worst.venue.liquidatePosition(user, worst.position.id); + + if ("feeEarned" in result) { + positionsClosed++; + totalFee += result.feeEarned; + reports.push({ + kind: "positionLeg", + venue: worst.venue.name, + feeEarned: result.feeEarned, + positionId: worst.position.id, + }); + } else if (result.skipped === "ordersStillOpen") { + // A new order appeared between the orders-leg and now (race with the + // matching engine, e.g. a fill leaving residual margin obligations). + // Re-run the orders-leg and retry on the next iteration. + log.warn( + { venue: worst.venue.name }, + "Position-leg hit OrdersStillOpen — replaying orders-leg and retrying", + ); + const replay = await this.runOrdersLeg(user, log); + reports.push(...replay); + for (const r of replay) { + totalFee += r.feeEarned; + ordersClosed += r.ordersClosed ?? 0; + } + } else { + // Either `notLiquidatable` (this position is no longer liquidatable + // — likely already-closed; loop to re-rank), or `unprofitable` (gas + // cost exceeds reward — bail rather than burn money). + reports.push({ + kind: "positionLeg", + venue: worst.venue.name, + feeEarned: 0n, + positionId: worst.position.id, + skipped: result.skipped, + }); + if (result.skipped === "unprofitable") { + log.warn( + { mmSurplus: health.mmSurplus, totalFee, positionsClosed }, + "Position-leg unprofitable — stalling", + ); + return { kind: "stalled", reason: "unprofitable", mmSurplus: health.mmSurplus }; + } + // notLiquidatable → loop and re-rank from a fresh snapshot. + } + + health = await this.readHealth(user); + if (health.mmSurplus >= 0n) { + log.info( + { mmSurplus: health.mmSurplus, totalFee, positionsClosed, ordersClosed }, + "Account healthy after position-leg — done", + ); + return { + kind: "liquidated", + mmSurplus: health.mmSurplus, + feeEarned: totalFee, + positionsClosed, + ordersClosed, + }; + } + } + + // Iteration cap hit. We've been making progress (iteration only counts + // up after a meaningful step) but couldn't bring the account healthy in + // the budget. Surface as `stalled` so the executor re-queues for a + // future sweep rather than exploding. + log.warn( + { + mmSurplus: health.mmSurplus, + totalFee, + positionsClosed, + ordersClosed, + iterCap: Planner.MAX_POSITION_ITERATIONS, + }, + "Planner.run: hit iteration cap, re-queueing", + ); + return { kind: "stalled", reason: "iterationCap", mmSurplus: health.mmSurplus }; + } + + /** + * Fans out `liquidateOrders(user)` across every venue. Each venue handles + * the "no orders" case internally and returns `{ skipped: "notLiquidatable" }` + * — we collapse that to a zero-fee no-op. + */ + private async runOrdersLeg(user: Address, log: pino.Logger): Promise { + const reports: StepReport[] = []; + for (const venue of this.venues) { + // Read first so we can both (a) report `ordersClosed` count for + // telemetry and (b) skip the call entirely when there are zero open + // orders — saves the `simulateContract` round-trip in the common case. + const openOrders = await venue.readOpenOrders(user); + if (openOrders.length === 0) { + reports.push({ kind: "ordersLeg", venue: venue.name, feeEarned: 0n, ordersClosed: 0 }); + continue; + } + const ids = openOrders.map((o) => o.id); + const result = await venue.liquidateOrders(user, ids); + if ("feeEarned" in result) { + log.info( + { venue: venue.name, count: openOrders.length, feeEarned: result.feeEarned }, + "Orders-leg: liquidated open orders", + ); + reports.push({ + kind: "ordersLeg", + venue: venue.name, + feeEarned: result.feeEarned, + ordersClosed: openOrders.length, + }); + } else { + // Race: orders cleared between read and call. Treat as a no-op. + reports.push({ + kind: "ordersLeg", + venue: venue.name, + feeEarned: 0n, + ordersClosed: 0, + skipped: result.skipped, + }); + } + } + return reports; + } + + /** + * Returns every (venue, position) pair across all venues, sorted + * most-underwater first. Primary key is `unrealizedLoss` DESC; tiebreak is + * `notional` DESC (closing the bigger position frees more margin). + */ + private async rankPositions( + user: Address, + ): Promise> { + const all: Array<{ venue: Venue; position: VenuePosition }> = []; + for (const venue of this.venues) { + const positions = await venue.readPositions(user); + for (const p of positions) all.push({ venue, position: p }); + } + all.sort((a, b) => { + if (a.position.unrealizedLoss !== b.position.unrealizedLoss) { + return a.position.unrealizedLoss < b.position.unrealizedLoss ? 1 : -1; + } + if (a.position.notional !== b.position.notional) { + return a.position.notional < b.position.notional ? 1 : -1; + } + return 0; + }); + return all; + } + + private async readHealth(user: Address): Promise { + const [h] = await readAccountHealthBatch(this.chain, this.config, [user]); + if (h === undefined) { + throw new Error(`readAccountHealthBatch returned no entry for ${user}`); + } + return h; + } +} diff --git a/keeper/src/coordinator/queue.ts b/keeper/src/coordinator/queue.ts new file mode 100644 index 0000000..dee1810 --- /dev/null +++ b/keeper/src/coordinator/queue.ts @@ -0,0 +1,95 @@ +import type { Address } from "viem"; +import type { AccountHealth } from "../pme/health.ts"; + +/** + * Min-heap-style priority queue ordered by `mmSurplus` ASC: the most-underwater + * account comes off first. + * + * **Underwater accounts only.** `upsert` accepts any `AccountHealth` snapshot + * but only enqueues entries with `mmSurplus < 0`. A snapshot showing the + * account is now healthy implicitly removes it from the queue. This means + * the executor never wastes a `planner.run` round-trip on a healthy user — + * the queue is exactly "things the executor must do". + * + * Implementation is a sorted-on-insert array. We expect O(10–100) underwater + * accounts at peak, far below the threshold where a binary heap matters; if + * that ever changes, the surface (`upsert`/`remove`/`pop`/`peek`/`size`) is + * heap-ready. + * + * `upsert` is keyed on `health.user`: re-evaluating an account just rewrites + * its position in the queue rather than inserting a stale duplicate. This is + * the contract every queue consumer relies on (sweeps fire repeatedly for + * the same user — multiple deposits, fills, etc.). + */ +export class CoordinatorQueue { + private items: AccountHealth[] = []; + + /** + * Inserts or replaces (by user address) keeping the queue sorted by + * `mmSurplus` ASC (most-underwater first). Healthy snapshots + * (`mmSurplus >= 0`) are dropped — and remove the user from the queue + * if they were previously enqueued. Returns true when the user is in the + * queue after this call. + */ + upsert(health: AccountHealth): boolean { + this.removeUser(health.user); + if (health.mmSurplus >= 0n) return false; + const insertAt = this.findInsertIndex(health); + this.items.splice(insertAt, 0, health); + return true; + } + + remove(user: Address): void { + this.removeUser(user); + } + + /** Pops the most-underwater account (smallest mmSurplus first). */ + pop(): AccountHealth | undefined { + return this.items.shift(); + } + + /** Non-destructive — useful for the planner's snapshot logic and for tests. */ + peek(): AccountHealth | undefined { + return this.items[0]; + } + + size(): number { + return this.items.length; + } + + /** Snapshot copy. Iterating the live queue while mutating it is a footgun. */ + snapshot(): readonly AccountHealth[] { + return [...this.items]; + } + + private removeUser(user: Address): void { + const idx = this.items.findIndex((h) => h.user === user); + if (idx >= 0) this.items.splice(idx, 1); + } + + private findInsertIndex(health: AccountHealth): number { + // Linear scan is fine at our scale; switch to binary search if N grows. + for (let i = 0; i < this.items.length; i++) { + const cur = this.items[i]; + if (cur === undefined) continue; + if (compare(health, cur) < 0) return i; + } + return this.items.length; + } +} + +/** + * Ordering: most-underwater first (mmSurplus ASC). Returns negative when `a` + * should come before `b`. Ties on bigint mmSurplus are vanishingly rare and + * arbitrarily ordered — by definition the queue only holds underwater + * accounts (`mmSurplus < 0`), so any tiebreak is moot for picking "who's + * most at risk". + * + * Exported for the unit test suite — keeps the policy auditable. + */ +export function compare(a: AccountHealth, b: AccountHealth): number { + if (a.mmSurplus === b.mmSurplus) return 0; + // BigInt compare → return -1/0/1 because Math.sign on a bigint difference + // truncates the wrong way for very large values. + return a.mmSurplus < b.mmSurplus ? -1 : 1; +} diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts new file mode 100644 index 0000000..3029123 --- /dev/null +++ b/keeper/src/discovery/tracker.ts @@ -0,0 +1,470 @@ +import { getAddress, type Address, type Hex, type Log, zeroAddress } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import { CollateralVaultAbi as collateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { HashPowerPerpsDEXAbi as perpsAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; +import { FuturesAbi as futuresAbi } from "futures-marketplace/Futures.ts"; + +/** + * Set of user addresses with collateral or open positions/orders that the + * keeper needs to monitor. Maintained event-driven via: + * + * - Vault Deposited / Withdrawn / Transfer → adds users on first deposit + * - Perps OrderCreated / OrderMatched / PositionLiquidated + * - Futures OrderCreated / PositionCreated / PositionLiquidated + * + * On startup, `backfill(fromBlock)` scans the same six events historically + * via `getLogs` so the cold-start window doesn't miss participants who + * funded or opened positions before the keeper booted. Steady state is + * carried by the live `watchContractEvent` subscriptions started in + * `start()`; backfill closes the gap from `fromBlock` up to the head of + * the watcher. + * + * The tracker is a "set of users to consider" — it never decides whether a + * user is liquidatable. That's the planner's job. Removing a user from the + * tracker is intentionally rare: we only drop them when we observe a + * `Withdrawn` that brings their vault balance back to zero AND they have no + * positions/orders. The cost of an extra `readAccountHealthBatch` call per + * dormant user is far smaller than the cost of missing a re-funding event. + */ +export type TrackerListener = (user: Address) => void; + +export class ParticipantTracker { + private readonly users = new Set
(); + private readonly addedListeners = new Set(); + private readonly changedListeners = new Set(); + /** Disposers returned by `watchContractEvent` — unwatched on `stop()`. */ + private unwatchers: Array<() => void> = []; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + + constructor(chain: Chain, config: Config, logger: pino.Logger) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ component: "tracker" }); + } + + /** + * Subscribes to the source-of-truth events on Vault, Perps and Futures. + * Discovery via RPC is enabled when `chain.discoveryMode` is `"events"` or + * `"both"`. The webhook path is owned by `WebhookIngester`, which feeds + * users in via `add()` directly. + */ + async start(): Promise { + if (this.config.chain.discoveryMode === "webhook") { + this.logger.info("discoveryMode=webhook — RPC subscriptions disabled"); + return; + } + this.logger.info({ mode: this.config.chain.discoveryMode }, "starting RPC event subscriptions"); + + // Each `watchContractEvent` returns an unwatcher fn; we call them all on + // stop(). Vault Transfer covers both `from` and `to` so we don't need to + // separately subscribe to ERC20 Approval (no balance change). + this.unwatchers.push( + this.chain.publicClient.watchContractEvent({ + address: this.config.vault.address, + abi: collateralVaultAbi, + eventName: "Deposited", + onLogs: (logs) => this.onVaultDeposited(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.vault.address, + abi: collateralVaultAbi, + eventName: "Transfer", + onLogs: (logs) => this.onVaultTransfer(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.perps.address, + abi: perpsAbi, + eventName: "OrderCreated", + onLogs: (logs) => this.onPerpsOrderCreated(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.perps.address, + abi: perpsAbi, + eventName: "OrderMatched", + onLogs: (logs) => this.onPerpsOrderMatched(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: futuresAbi, + eventName: "OrderCreated", + onLogs: (logs) => this.onFuturesOrderCreated(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: futuresAbi, + eventName: "PositionCreated", + onLogs: (logs) => this.onFuturesPositionCreated(logs), + }), + ); + } + + /** Tears down all subscriptions. Idempotent. */ + stop(): void { + for (const u of this.unwatchers) { + try { + u(); + } catch (err) { + this.logger.warn({ err }, "unwatcher threw — continuing shutdown"); + } + } + this.unwatchers = []; + } + + /** + * One-shot historical backfill. Scans the same six events `start()` + * subscribes to from `fromBlock` to the current head via `getLogs`, in + * chunks of `chunkSize` blocks, and feeds each match through the same + * handlers the live watcher uses. Run once at startup *after* `start()` + * has wired the forward subscriptions — the small overlap between the + * scan head and the watcher's polling cursor is fine, because `add()` + * dedupes on checksum. + * + * Futures has no `getUsersWithPositions` view on-chain, so historical + * `OrderCreated` / `PositionCreated` logs are the only source of cold- + * start participants. Perps has the view but we use logs uniformly so a + * single backfill mechanism covers both venues (and the vault). + * + * Webhook-only discovery mode skips backfill — Goldsky owns history in + * that configuration. + */ + async backfill(fromBlock: bigint, chunkSize: bigint): Promise { + if (this.config.chain.discoveryMode === "webhook") { + this.logger.info("discoveryMode=webhook — backfill skipped"); + return; + } + if (chunkSize <= 0n) { + throw new Error(`backfill chunkSize must be positive, got ${chunkSize}`); + } + + const head = await this.chain.publicClient.getBlockNumber(); + if (fromBlock > head) { + this.logger.warn( + { fromBlock: fromBlock.toString(), head: head.toString() }, + "backfill fromBlock > head — nothing to do", + ); + return; + } + + const before = this.users.size; + this.logger.info( + { + fromBlock: fromBlock.toString(), + head: head.toString(), + chunkSize: chunkSize.toString(), + }, + "backfill: starting", + ); + + // Each source = one event we live-subscribe to in `start()`. We page + // through the block range independently per source so a single failing + // RPC call only drops that source's contribution, not the whole pass. + // The `dispatch` for each source is the SAME function the live watcher + // calls in `start()` — historical and live logs land in identical code + // paths, so any future field renames touch exactly one place. + const sources: Array<{ + label: string; + run: (from: bigint, to: bigint) => Promise; + }> = [ + { + label: "vault.Deposited", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.vault.address, + abi: collateralVaultAbi, + eventName: "Deposited", + fromBlock: from, + toBlock: to, + }); + this.onVaultDeposited(logs as unknown as readonly Log[]); + }, + }, + { + label: "vault.Transfer", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.vault.address, + abi: collateralVaultAbi, + eventName: "Transfer", + fromBlock: from, + toBlock: to, + }); + this.onVaultTransfer(logs as unknown as readonly Log[]); + }, + }, + { + label: "perps.OrderCreated", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.perps.address, + abi: perpsAbi, + eventName: "OrderCreated", + fromBlock: from, + toBlock: to, + }); + this.onPerpsOrderCreated(logs as unknown as readonly Log[]); + }, + }, + { + label: "perps.OrderMatched", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.perps.address, + abi: perpsAbi, + eventName: "OrderMatched", + fromBlock: from, + toBlock: to, + }); + this.onPerpsOrderMatched(logs as unknown as readonly Log[]); + }, + }, + { + label: "futures.OrderCreated", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: futuresAbi, + eventName: "OrderCreated", + fromBlock: from, + toBlock: to, + }); + this.onFuturesOrderCreated(logs as unknown as readonly Log[]); + }, + }, + { + label: "futures.PositionCreated", + run: async (from, to) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: futuresAbi, + eventName: "PositionCreated", + fromBlock: from, + toBlock: to, + }); + this.onFuturesPositionCreated(logs as unknown as readonly Log[]); + }, + }, + ]; + + for (const source of sources) { + let chunkErrors = 0; + for (let start = fromBlock; start <= head; start += chunkSize) { + const end = start + chunkSize - 1n > head ? head : start + chunkSize - 1n; + try { + await source.run(start, end); + } catch (err) { + chunkErrors++; + this.logger.error( + { + err, + source: source.label, + from: start.toString(), + to: end.toString(), + }, + "backfill chunk failed", + ); + } + } + if (chunkErrors > 0) { + this.logger.warn( + { source: source.label, chunkErrors }, + "backfill source completed with chunk errors — some users may be missing until next event", + ); + } + } + + const added = this.users.size - before; + this.logger.info( + { added, total: this.users.size, head: head.toString() }, + "backfill: complete", + ); + } + + /** + * Manually add a user. Used by `WebhookIngester` and by external callers + * that need to inject a user (e.g. ad-hoc CLI commands). + */ + add(user: Address): boolean { + const checksummed = getAddress(user); + if (this.users.has(checksummed)) return false; + this.users.add(checksummed); + this.logger.debug({ user: checksummed, total: this.users.size }, "tracker.add"); + for (const l of this.addedListeners) { + try { + l(checksummed); + } catch (err) { + this.logger.error({ err, user: checksummed }, "added listener threw"); + } + } + return true; + } + + addBatch(users: readonly Address[]): number { + let added = 0; + for (const u of users) if (this.add(u)) added++; + return added; + } + + /** Returns true if the user was tracked. We rarely call this — see class doc. */ + remove(user: Address): boolean { + return this.users.delete(getAddress(user)); + } + + list(): Address[] { + return Array.from(this.users); + } + + size(): number { + return this.users.size; + } + + has(user: Address): boolean { + return this.users.has(getAddress(user)); + } + + /** + * Subscribe to add events. Used by the runtime layer to refresh + * `AccountHealth` and re-rank the queue whenever a new participant is + * discovered. Returns an unsubscribe function. + */ + onAdded(listener: TrackerListener): () => void { + this.addedListeners.add(listener); + return () => this.addedListeners.delete(listener); + } + + /** + * Subscribe to "user state may have changed" events. Fires for the same + * triggers `onAdded` does, plus any time a tracked user's state could + * have shifted (vault transfer in/out, perps OrderCreated/Matched, + * futures OrderCreated/PositionCreated). + * + * The predictive layer uses this to invalidate and rebuild a user's + * cached MM snapshot. Listeners must tolerate being called for users + * they don't track (we don't filter — checking `users.has` here would + * race with `add`). + */ + onChanged(listener: TrackerListener): () => void { + this.changedListeners.add(listener); + return () => this.changedListeners.delete(listener); + } + + /** + * Internal: fire the `changed` listeners for `user`. Called by every log + * handler that observes a state-changing event. We swallow exceptions so + * one bad listener can't poison the watcher. + */ + private notifyChanged(user: Address): void { + for (const l of this.changedListeners) { + try { + l(user); + } catch (err) { + this.logger.error({ err, user }, "changed listener threw"); + } + } + } + + // -- log handlers --------------------------------------------------------- + // One handler per (contract, event) — never branch inside on event kind. + // Each handler types the `args` shape to the exact event's payload so a + // future ABI rename surfaces as a compile error here rather than silent + // data loss. Logs missing `args` (malformed / undecodable) are skipped — + // better to miss a candidate than to crash the watcher. + + /** + * Helper used by every log handler: ensure `user` is tracked AND notify + * the `changed` listeners. Splitting "add" from "changed" lets the + * predictive layer rebuild a user's snapshot on every relevant event, + * not just the first one. + */ + private touch(user: Address): void { + this.add(user); + this.notifyChanged(getAddress(user)); + } + + /** + * `Deposited(address indexed user, uint256 amount, address indexed sender)`. + * Only `user` (the credited account) is the keeper's concern — `sender` + * is the funding wallet and doesn't own the resulting balance. + */ + private onVaultDeposited(logs: readonly Log[]): void { + type Args = { user?: Address; sender?: Address; amount?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.user !== undefined) this.touch(args.user); + } + } + + /** + * `Transfer(address indexed from, address indexed to, uint256 value)`. + * Track both sides — the destination becomes a candidate; the source we + * keep tracking even if its balance zeroes out (cheap to keep, expensive + * to miss on re-funding). + */ + private onVaultTransfer(logs: readonly Log[]): void { + type Args = { from?: Address; to?: Address; value?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args === undefined) continue; + if (args.from !== undefined && args.from !== zeroAddress) this.touch(args.from); + if (args.to !== undefined && args.to !== zeroAddress) this.touch(args.to); + } + } + + /** + * `OrderCreated(bytes32 indexed orderId, address indexed participant, + * uint256 price, int256 quantity)`. + * NOTE: the perps event field is `participant`, not `user`. + */ + private onPerpsOrderCreated(logs: readonly Log[]): void { + type Args = { orderId?: Hex; participant?: Address; price?: bigint; quantity?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.participant !== undefined) this.touch(args.participant); + } + } + + /** + * `OrderMatched(bytes32 indexed makerOrderId, address indexed maker, + * address indexed taker, uint256 tradePrice, ...)`. + */ + private onPerpsOrderMatched(logs: readonly Log[]): void { + type Args = { makerOrderId?: Hex; maker?: Address; taker?: Address }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args === undefined) continue; + if (args.maker !== undefined) this.touch(args.maker); + if (args.taker !== undefined) this.touch(args.taker); + } + } + + /** + * `OrderCreated(bytes32 indexed orderId, address indexed participant, + * string destURL, uint256 pricePerDay, uint256 deliveryAt, + * bool isBuy)`. + */ + private onFuturesOrderCreated(logs: readonly Log[]): void { + type Args = { orderId?: Hex; participant?: Address }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.participant !== undefined) this.touch(args.participant); + } + } + + /** + * `PositionCreated(bytes32 indexed positionId, address indexed seller, + * address indexed buyer, uint256 sellPricePerDay, ...)`. + */ + private onFuturesPositionCreated(logs: readonly Log[]): void { + type Args = { positionId?: Hex; seller?: Address; buyer?: Address }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args === undefined) continue; + if (args.seller !== undefined) this.touch(args.seller); + if (args.buyer !== undefined) this.touch(args.buyer); + } + } +} diff --git a/keeper/src/discovery/webhook.ts b/keeper/src/discovery/webhook.ts new file mode 100644 index 0000000..1abadc0 --- /dev/null +++ b/keeper/src/discovery/webhook.ts @@ -0,0 +1,198 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { isAddress, type Address } from "viem"; +import type pino from "pino"; +import type { Config } from "../config.ts"; +import type { ParticipantTracker } from "./tracker.ts"; + +/** + * Optional Goldsky webhook ingester. Listens for indexed entity changes + * (vault deposits, perps/futures order/position events) on a local HTTP port + * and feeds them into the ParticipantTracker. + * + * Used in addition to (or instead of) RPC event subscriptions, controlled by + * `chain.discoveryMode`: + * - "events" — RPC only (default, simplest deployment) + * - "webhook" — Goldsky only (lowest RPC cost) + * - "both" — both, deduped by the tracker's per-address Set + * + * Goldsky's payload shape is configurable per-pipe; we accept the most + * flexible form below — a JSON document with a top-level `data` array + * whose entries each carry a `user` / `participant` / `to` / `from` / + * `seller` / `buyer` field. Anything else is ignored. + * + * Auth: when `WEBHOOK_SECRET` is configured, the request must carry a + * matching `Authorization: Bearer ` header. Without a configured + * secret the endpoint is open — fine for local dev, do not deploy. + */ +export class WebhookIngester { + private readonly config: Config; + private readonly tracker: ParticipantTracker; + private readonly logger: pino.Logger; + private server: Server | undefined; + + constructor(config: Config, tracker: ParticipantTracker, logger: pino.Logger) { + this.config = config; + this.tracker = tracker; + this.logger = logger.child({ component: "webhook" }); + } + + async start(): Promise { + if (this.config.chain.discoveryMode === "events") { + this.logger.info("discoveryMode=events — webhook ingester disabled"); + return; + } + const port = this.config.triggers.webhookPort; + this.server = createServer((req, res) => { + this.handleRequest(req, res).catch((err) => { + this.logger.error({ err }, "request handler threw"); + if (!res.headersSent) { + res.statusCode = 500; + res.end(); + } + }); + }); + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + this.server!.once("error", onError); + this.server!.listen(port, () => { + this.server!.off("error", onError); + this.logger.info({ port }, "webhook ingester listening"); + resolve(); + }); + }); + } + + async stop(): Promise { + if (this.server === undefined) return; + const srv = this.server; + this.server = undefined; + await new Promise((resolve) => srv.close(() => resolve())); + this.logger.info("webhook ingester stopped"); + } + + /** + * Visible for tests — handles a single parsed payload as if it had come in + * over HTTP. Returns the number of users newly added to the tracker. + */ + ingest(payload: unknown): number { + const candidates = extractAddresses(payload); + let added = 0; + for (const addr of candidates) { + if (this.tracker.add(addr)) added++; + } + return added; + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("allow", "POST"); + res.end(); + return; + } + + if (!this.checkAuth(req)) { + res.statusCode = 401; + res.end(); + return; + } + + const body = await readBody(req); + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + res.statusCode = 400; + res.end("invalid json"); + return; + } + + const added = this.ingest(payload); + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ added })); + } + + private checkAuth(req: IncomingMessage): boolean { + const expected = this.config.triggers.webhookSecret; + if (expected === undefined || expected === "") return true; + const header = req.headers.authorization; + if (typeof header !== "string") return false; + const m = header.match(/^Bearer\s+(.+)$/i); + if (m === null) return false; + return m[1] === expected; + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + req.setEncoding("utf8"); + req.on("data", (chunk: string) => { + data += chunk; + // Defense against pathological clients — Goldsky payloads are tiny. + if (data.length > 1_000_000) { + reject(new Error("payload too large")); + req.destroy(); + } + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +/** + * Pulls every plausible address out of a webhook payload. We check several + * common Goldsky shapes: + * - `{ data: [...] }` — the standard Pipe payload + * - `{ records: [...] }` — older Pipe schema + * - top-level array + * - top-level object containing the address fields directly + * + * For each record we look at `user`, `participant`, `from`, `to`, `seller`, + * `buyer`, `liquidator`, `maker`, `taker`. Anything matching `isAddress` + * goes into the result set; everything else is silently dropped. Returning + * a `Set` (cast to array) gives us payload-level dedupe before the tracker + * call. + * + * Exported (via `__testing`) so the unit tests can assert directly on the + * extraction layer without standing up an HTTP server. + */ +function extractAddresses(payload: unknown): readonly Address[] { + const found = new Set
(); + const records = unwrapRecords(payload); + const FIELDS = [ + "user", + "participant", + "from", + "to", + "seller", + "buyer", + "liquidator", + "maker", + "taker", + ] as const; + for (const r of records) { + if (typeof r !== "object" || r === null) continue; + const rec = r as Record; + for (const f of FIELDS) { + const v = rec[f]; + if (typeof v === "string" && isAddress(v)) { + found.add(v as Address); + } + } + } + return Array.from(found); +} + +function unwrapRecords(payload: unknown): readonly unknown[] { + if (Array.isArray(payload)) return payload; + if (typeof payload !== "object" || payload === null) return []; + const obj = payload as Record; + if (Array.isArray(obj.data)) return obj.data; + if (Array.isArray(obj.records)) return obj.records; + // Fall back to treating the whole object as one record. + return [obj]; +} + +export const __testing = { extractAddresses, unwrapRecords }; diff --git a/keeper/src/index.ts b/keeper/src/index.ts new file mode 100644 index 0000000..85fac82 --- /dev/null +++ b/keeper/src/index.ts @@ -0,0 +1,190 @@ +import pino from "pino"; +import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { loadConfig } from "./config.ts"; +import { createChain } from "./chain.ts"; +import { ParticipantTracker } from "./discovery/tracker.ts"; +import { WebhookIngester } from "./discovery/webhook.ts"; +import { CoordinatorQueue } from "./coordinator/queue.ts"; +import { Planner } from "./coordinator/planner.ts"; +import { CoordinatorExecutor } from "./coordinator/executor.ts"; +import { Notifier } from "./alert/notifier.ts"; +import { Healthcheck } from "./runtime/healthcheck.ts"; +import { Scheduler } from "./runtime/scheduler.ts"; +import { PerpsVenue } from "./venues/perps.ts"; +import { FuturesVenue } from "./venues/futures.ts"; +import { PriceFeed } from "./oracle/priceFeed.ts"; +import { PredictiveCoordinator } from "./predict/coordinator.ts"; +import type { Venue } from "./venues/types.ts"; + +/** + * Single long-running coordinator. No Lambda. One signer. Two venues today + * (perps, futures), trivially extensible to options once it's live. + * + * Wiring order: + * 1. Load config + open RPC. + * 2. Build venue adapters (one per Perps / Futures). + * 3. Stand up the coordinator queue + planner + executor. + * 4. Wire the tracker → executor edge: a newly-discovered user kicks the + * executor so the next sweep picks them up immediately. + * 5. Start ParticipantTracker (events) and optionally WebhookIngester. + * 6. Start the periodic Scheduler (safety-net sweep). + * 7. Start the healthcheck server. + * 8. Run a one-shot historical backfill (vault + perps + futures logs) so + * the tracker is primed before the first sweep — closes the cold-start + * gap that the live event subscriptions can't see. + * 9. Wait for SIGINT / SIGTERM, then stop everything in reverse order. + */ +async function main(): Promise { + const config = loadConfig(); + const logger = pino({ level: config.runtime.logLevel }); + + logger.info( + { + network: config.chain.network, + perps: config.perps.address, + futures: config.futures.address, + vault: config.vault.address, + pme: config.pme.address, + dryRun: config.keeper.dryRun, + discoveryMode: config.chain.discoveryMode, + }, + "Starting collateral-margin keeper", + ); + + const chain = createChain(config); + logger.info({ liquidator: chain.account.address }, "Wallet ready"); + + // Read the vault's `decimals()` once at startup so every consumer (price + // feed, planners, alerts) speaks the same units as on-chain balances. The + // vault mirrors the wrapped collateral token's decimals on init, so this + // is the canonical source — avoids a hard-coded "USDC = 6" that silently + // drifts if we ever swap collateral assets. + const tokenDecimals = await chain.publicClient.readContract({ + address: config.vault.address, + abi: CollateralVaultAbi, + functionName: "decimals", + }); + logger.info({ tokenDecimals }, "Collateral token decimals"); + + const venues: Venue[] = [ + new PerpsVenue(chain, config, logger), + new FuturesVenue(chain, config, logger), + ]; + + const notifier = new Notifier(config, logger); + const tracker = new ParticipantTracker(chain, config, logger); + const queue = new CoordinatorQueue(); + const planner = new Planner(chain, config, venues, logger); + const executor = new CoordinatorExecutor(config, queue, planner, logger); + const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, logger); + + // Predictive layer: subscribes to BTC/USDC AnswerUpdated events, reads + // the current HashpriceUSDC value, and pre-computes per-user liquidation + // thresholds so price ticks feed the coordinator queue directly. The + // periodic Scheduler stays as a safety net at a relaxed cadence. + const priceFeed = new PriceFeed(chain, config, logger, tokenDecimals); + const predictor = new PredictiveCoordinator( + chain, + config, + tracker, + queue, + executor, + priceFeed, + logger, + notifier, + ); + + const health = new Healthcheck(config, tracker, executor, queue, logger, predictor, priceFeed); + + let webhookIngester: WebhookIngester | undefined; + if (config.chain.discoveryMode !== "events") { + webhookIngester = new WebhookIngester(config, tracker, logger); + } + + // 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 + // AccountHealth snapshot — that lives in the scheduler.) + tracker.onAdded(() => executor.kick()); + + // ── Graceful shutdown ───────────────────────────────────────────────── + // Predictor / priceFeed stop before the tracker so their listeners + // unhook before the tracker goes away. + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ signal }, "Shutting down…"); + await health.stop(); + scheduler.stop(); + predictor.stop(); + priceFeed.stop(); + await executor.stop(); + if (webhookIngester !== undefined) await webhookIngester.stop(); + tracker.stop(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + // ── Start ───────────────────────────────────────────────────────────── + // PriceFeed first: primes `current()` with one read so the predictor has + // a baseline before the first tracker event fires. Predictor next so its + // tracker hooks are in place before tracker.start() flushes any backlog. + await priceFeed.start(); + await predictor.start(); + await tracker.start(); + if (webhookIngester !== undefined) await webhookIngester.start(); + await executor.start(); + scheduler.start(); + health.start(); + + // Pull initial state so the first sweep tick has something to chew on + // instead of waiting on event traffic. Backfill scans the same discovery + // events the tracker live-subscribes to, from `backfillFromBlock` up to + // the current head, then `runSweep` reads health for everyone we found. + // Each newly-added user fires `tracker.onAdded`, which the predictor + // consumes via `rebuild` — so the predictor index also gets seeded here. + // No backfill anchor → forward-only (only safe with webhook discovery or + // a prior keeper that's already populated the set out-of-band). + if (config.chain.backfillFromBlock !== undefined) { + await tracker.backfill(config.chain.backfillFromBlock, config.chain.backfillChunkSize); + } else { + logger.warn( + "BACKFILL_FROM_BLOCK unset — skipping historical scan; cold-start may miss participants until they next emit an event", + ); + } + await scheduler.runSweep(); + // 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" + // — otherwise the first health probe can race a half-built index. + await predictor.awaitIdle(); + + // If we discovered users but couldn't index any, something is wrong + // with the snapshot path (RPC, ABI mismatch, oracle missing) — surface + // it loudly. Tracker > 0 but predictor = 0 is a real outage shape. + if (tracker.size() > 0 && predictor.size() === 0) { + logger.warn( + { tracked: tracker.size() }, + "tracker has users but predictor index is empty — snapshot path may be failing; check earlier 'rebuild failed' logs", + ); + } + + logger.info( + { + tracked: tracker.size(), + predicted: predictor.size(), + currentPrice: priceFeed.current()?.toString(), + }, + "Keeper is running", + ); +} + +main().catch((err) => { + // Fail hard so the orchestrator restarts the pod with full logs. + // Using stderr directly avoids pino formatting on a logger that might not + // be initialised yet (e.g. config load failure). + process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`); + process.exit(1); +}); diff --git a/keeper/src/oracle/abi.ts b/keeper/src/oracle/abi.ts new file mode 100644 index 0000000..db2c816 --- /dev/null +++ b/keeper/src/oracle/abi.ts @@ -0,0 +1,20 @@ +import { parseAbi } from "viem"; + +/** + * Minimal Chainlink `AggregatorV3` / `AggregatorProxy` surface — three + * entries are all the predictive layer needs: + * + * - `AnswerUpdated` event: trigger to re-evaluate the price index. + * - `latestRoundData`: read the current answer from the aggregator. + * - `decimals`: rebase the answer to the venue's token decimals. + * + * Inlined as a human-readable signature list to keep the keeper free of any + * dependency on `@chainlink/contracts`. The shape matches both Chainlink's + * proxy aggregator and the in-house `HashpriceUSD` contract (which + * implements `AggregatorV3Interface` directly). + */ +export const AggregatorV3Abi = parseAbi([ + "event AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt)", + "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)", + "function decimals() view returns (uint8)", +]); diff --git a/keeper/src/oracle/priceFeed.ts b/keeper/src/oracle/priceFeed.ts new file mode 100644 index 0000000..76f3443 --- /dev/null +++ b/keeper/src/oracle/priceFeed.ts @@ -0,0 +1,183 @@ +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import { AggregatorV3Abi } from "./abi.ts"; + +/** + * A single tick of the hashprice oracle, in token decimals (USDC = 6). + * `at` is the wall-clock receipt timestamp (set when we read the value, not + * the Chainlink `updatedAt`) — used by consumers to discard ticks they've + * already processed. + */ +export interface PriceUpdate { + /** Previous price (in token decimals). `undefined` on the first tick. */ + prev: bigint | undefined; + /** New price (in token decimals). */ + next: bigint; + /** Receipt time in ms-since-epoch. */ + at: number; +} + +export type PriceListener = (update: PriceUpdate) => void; + +/** + * Watches the BTC/USDC Chainlink feed for `AnswerUpdated` events and, on + * each event, re-reads the current `HashpriceUSD` answer. Emits a + * `PriceUpdate` to every subscribed listener. + * + * Why this split: + * - `HashpriceUSD = HashpriceBTC × BTC/USD / scale`. BTC/USD moves on + * Chainlink's deviation/heartbeat triggers (often, sub-minute on + * volatile days); HashpriceBTC moves only when a BTC block is mined + * and `submitBlock` is called (~10 min cadence). + * - BTC/USD is therefore the dominant driver of HashpriceUSD changes. + * Subscribing to one feed and reading the aggregated value gives us + * fresh `HashpriceUSD` values without polling either upstream. + * - The slower HashpriceBTC drift falls to the periodic safety-net sweep. + * + * The feed also handles the upstream-decimals → token-decimals rebase: the + * aggregator answer is `oracle.decimals()` (typically 8 for HashpriceUSD); + * we rescale to the perps/futures token decimals (USDC = 6) so consumers + * compare apples to apples with `getMarketPrice()`. + * + * Lifecycle: + * - `start()`: read decimals, prime `current` via one `latestRoundData`, + * then attach the watcher. Returns once the first read has resolved. + * - `stop()`: detach the watcher. Idempotent. + * - `current()`: latest known price; `undefined` until first read. + * - `onUpdate(listener)`: subscribe; returns an unsubscribe fn. + */ +export class PriceFeed { + private listeners: Set = new Set(); + private currentPrice: bigint | undefined; + private unwatch: (() => void) | undefined; + /** 10^(oracleDecimals - tokenDecimals). Set during `start()`. */ + private rescaleDivisor: bigint = 1n; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + /** Token decimals of the collateral / venue answer (USDC = 6). */ + private readonly tokenDecimals: number; + + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + tokenDecimals: number, + ) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ component: "priceFeed" }); + this.tokenDecimals = tokenDecimals; + } + + async start(): Promise { + if (this.unwatch !== undefined) { + this.logger.warn("PriceFeed.start: already running"); + return; + } + + const oracleDecimals = (await this.chain.publicClient.readContract({ + address: this.config.oracle.hashpriceUsdcAddress, + abi: AggregatorV3Abi, + functionName: "decimals", + })) as number; + + if (oracleDecimals < this.tokenDecimals) { + throw new Error( + `PriceFeed: oracle decimals (${oracleDecimals}) < token decimals (${this.tokenDecimals})`, + ); + } + this.rescaleDivisor = 10n ** BigInt(oracleDecimals - this.tokenDecimals); + + await this.refresh("start"); + + // We watch BTC/USDC (not HashpriceUSD) because HashpriceUSD is a pure + // composite view and emits no events of its own. Any BTC/USDC tick + // potentially shifts HashpriceUSD, so we re-read on every event. + this.unwatch = this.chain.publicClient.watchContractEvent({ + address: this.config.oracle.btcUsdcFeedAddress, + abi: AggregatorV3Abi, + eventName: "AnswerUpdated", + onLogs: () => { + // Fire-and-forget: refresh runs in the background and dispatches to + // listeners. If a refresh is already in flight, the next event will + // overlap — that's fine, listeners only react to monotonic changes. + void this.refresh("event"); + }, + }); + + this.logger.info( + { + hashpriceUsdc: this.config.oracle.hashpriceUsdcAddress, + btcUsdcFeed: this.config.oracle.btcUsdcFeedAddress, + oracleDecimals, + tokenDecimals: this.tokenDecimals, + currentPrice: this.currentPrice, + }, + "PriceFeed started", + ); + } + + stop(): void { + if (this.unwatch !== undefined) { + try { + this.unwatch(); + } catch (err) { + this.logger.warn({ err }, "PriceFeed.stop: unwatch threw"); + } + this.unwatch = undefined; + } + } + + current(): bigint | undefined { + return this.currentPrice; + } + + onUpdate(listener: PriceListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Re-read `latestRoundData`, rescale to token decimals, dispatch if the + * value actually changed. Public for tests and for the runtime layer to + * force a refresh after restart / on RPC reconnect. + */ + async refresh(source: "start" | "event" | "manual"): Promise { + let answer: bigint; + try { + const data = (await this.chain.publicClient.readContract({ + address: this.config.oracle.hashpriceUsdcAddress, + abi: AggregatorV3Abi, + functionName: "latestRoundData", + })) as readonly [bigint, bigint, bigint, bigint, bigint]; + answer = data[1]; + } catch (err) { + this.logger.error({ err, source }, "PriceFeed.refresh: read failed"); + return; + } + + if (answer <= 0n) { + this.logger.warn({ answer, source }, "PriceFeed.refresh: non-positive answer, skipping"); + return; + } + + const next = answer / this.rescaleDivisor; + const prev = this.currentPrice; + if (prev === next) return; + + this.currentPrice = next; + const update: PriceUpdate = { prev, next, at: Date.now() }; + this.logger.debug({ prev, next, source }, "PriceFeed update"); + + for (const l of this.listeners) { + try { + l(update); + } catch (err) { + this.logger.error({ err, source }, "PriceFeed listener threw"); + } + } + } +} diff --git a/keeper/src/pme/health.ts b/keeper/src/pme/health.ts new file mode 100644 index 0000000..e2e0b58 --- /dev/null +++ b/keeper/src/pme/health.ts @@ -0,0 +1,104 @@ +import type { Address } from "viem"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { PortfolioMarginEngineAbi } from "collateral-margin/PortfolioMarginEngine.ts"; + +/** + * Snapshot of an account's portfolio-margin state at a single block. + * + * `mmSurplus` and `imRequired` are sourced from the PortfolioMarginEngine + * (single source of truth — both venues' on-chain liquidation predicates + * resolve back to it). + */ +export interface AccountHealth { + user: Address; + balance: bigint; + imRequired: bigint; + mmRequired: bigint; + /** balance - mmRequired. Negative = liquidatable. */ + mmSurplus: bigint; + /** imRequired / balance. >1 means below IM. Used by the alert ranker. */ + imUtilization: number; +} + +/** Default chunk size for the multicall. Each user costs 3 calls. */ +const DEFAULT_CHUNK_SIZE = 64; + +/** + * Reads `(balanceOf, computePortfolioIM, computePortfolioMM)` for every + * supplied user in a single multicall (chunked when the user list is large). + */ +export async function readAccountHealthBatch( + chain: Chain, + config: Config, + users: readonly Address[], + chunkSize: number = DEFAULT_CHUNK_SIZE, +): Promise { + if (users.length === 0) return []; + + const result: AccountHealth[] = []; + for (let i = 0; i < users.length; i += chunkSize) { + const chunk = users.slice(i, i + chunkSize); + const calls = chunk.flatMap((user) => [ + { + address: config.vault.address, + abi: CollateralVaultAbi, + functionName: "balanceOf" as const, + args: [user] as const, + }, + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioIM" as const, + args: [user] as const, + }, + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioMM" as const, + args: [user] as const, + }, + ]); + + const reads = await chain.publicClient.multicall({ + contracts: calls, + allowFailure: false, + }); + + chunk.forEach((user, j) => { + const balance = reads[j * 3] as bigint; + const imRequired = reads[j * 3 + 1] as bigint; + const mmRequired = reads[j * 3 + 2] as bigint; + result.push({ + user, + balance, + imRequired, + mmRequired, + mmSurplus: balance - mmRequired, + imUtilization: computeUtilization(imRequired, balance), + }); + }); + } + + return result; +} + +/** + * `imRequired / balance` as a JS `number`. Returns: + * - `0` when both balance and imRequired are 0 (idle account) + * - `Infinity` when balance is 0 but imRequired isn't (broken — already underwater) + * - clamped to a finite number otherwise + * + * We accept the precision loss because this value only drives alert ranking + * (warn / critical thresholds are configured as JS numbers in `Config`); the + * MM predicate itself stays in BigInt land via `mmSurplus`. + */ +export function computeUtilization(imRequired: bigint, balance: bigint): number { + if (balance === 0n) { + return imRequired === 0n ? 0 : Number.POSITIVE_INFINITY; + } + // Scale into ppm so we keep ~6 decimal digits of precision before the float cast. + const ppm = (imRequired * 1_000_000n) / balance; + return Number(ppm) / 1_000_000; +} diff --git a/keeper/src/predict/coordinator.ts b/keeper/src/predict/coordinator.ts new file mode 100644 index 0000000..42382cb --- /dev/null +++ b/keeper/src/predict/coordinator.ts @@ -0,0 +1,341 @@ +import type { Address } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import type { CoordinatorQueue } from "../coordinator/queue.ts"; +import type { CoordinatorExecutor } from "../coordinator/executor.ts"; +import type { ParticipantTracker } from "../discovery/tracker.ts"; +import type { PriceFeed, PriceUpdate } from "../oracle/priceFeed.ts"; +import type { Notifier } from "../alert/notifier.ts"; +import { readAccountHealthBatch } from "../pme/health.ts"; +import { readAccountSnapshot, readMMParams } from "./snapshot.ts"; +import { solveAlertThresholds, solveLiquidationThresholds } from "./solve.ts"; +import { PredictiveIndex } from "./predictiveIndex.ts"; +import type { MMParams } from "./types.ts"; + +/** + * Wires the predictive layer into the existing keeper: + * + * ParticipantTracker ──onChanged──▶ invalidate + rebuild snapshot + * PriceFeed ──onUpdate ──▶ detect crossings → enqueue users + * + * When a price tick crosses a user's predicted liquidation threshold: + * 1. Read fresh on-chain `AccountHealth` for that user (multicall — same + * cost as one position in the periodic sweep). + * 2. `queue.upsert(health)` — the queue gates on `mmSurplus < 0`, so a + * false-positive prediction (model drift) costs at most one cheap + * health read. + * 3. `executor.kick()` to wake any idle workers immediately. + * + * The on-chain `mmRequired` remains the source of truth — the predictor + * only decides *who* and *when* to look. Model drift therefore can only + * cause a spurious queue insert (planner sees healthy, bails), never a + * spurious liquidation transaction. + * + * Lifecycle: + * - `start()`: load shared `MMParams`, hook tracker.onChanged, hook + * priceFeed.onUpdate. Returns immediately. + * - `stop()`: detach hooks. In-flight `rebuild` calls finish; nothing + * gracefully cancellable in the snapshot reader. + * - `rebuild(user)`: read fresh snapshot + solve + index.upsert. Public + * for the runtime to seed the index after `tracker.backfill()`. + */ +export class PredictiveCoordinator { + /** Liquidation crossings — drive the coordinator queue. */ + private readonly liqIndex = new PredictiveIndex(); + /** IM warn crossings — fire warn alerts on the notifier. */ + private readonly warnIndex = new PredictiveIndex(); + /** IM critical crossings — fire critical alerts on the notifier. */ + private readonly critIndex = new PredictiveIndex(); + private params: MMParams | undefined; + /** Disposers unsubscribe from subscribtions */ + private disposers: Array<() => void> = []; + /** In-flight rebuilds, keyed by user — coalesces rapid event bursts. */ + private inflightRebuilds = new Map>(); + + private readonly chain: Chain; + private readonly config: Config; + private readonly tracker: ParticipantTracker; + private readonly queue: CoordinatorQueue; + private readonly executor: CoordinatorExecutor; + private readonly priceFeed: PriceFeed; + private readonly notifier: Notifier | undefined; + private readonly logger: pino.Logger; + + constructor( + chain: Chain, + config: Config, + tracker: ParticipantTracker, + queue: CoordinatorQueue, + executor: CoordinatorExecutor, + priceFeed: PriceFeed, + logger: pino.Logger, + notifier?: Notifier, + ) { + this.chain = chain; + this.config = config; + this.tracker = tracker; + this.queue = queue; + this.executor = executor; + this.priceFeed = priceFeed; + this.notifier = notifier; + this.logger = logger.child({ component: "predictiveCoordinator" }); + } + + async start(): Promise { + this.params = await readMMParams(this.chain, this.config); + this.logger.info( + { + imSpotShock: this.params.imSpotShock, + mmSpotShock: this.params.mmSpotShock, + tokenDecimals: this.params.tokenDecimals, + }, + "PredictiveCoordinator: MM params loaded", + ); + + // New users → build their first snapshot. Existing users with state + // changes are also funnelled through here, so we avoid two listeners. + this.disposers.push(this.tracker.onAdded((user) => void this.rebuild(user))); + this.disposers.push(this.tracker.onChanged((user) => void this.rebuild(user))); + this.disposers.push(this.priceFeed.onUpdate((user) => this.handlePriceUpdate(user))); + } + + stop(): void { + for (const dispose of this.disposers) { + try { + dispose(); + } catch (err) { + this.logger.warn({ err }, "PredictiveCoordinator.stop: disposer threw"); + } + } + this.disposers = []; + } + + /** + * Read a fresh snapshot for `user`, solve its thresholds, and update the + * index. Coalesces concurrent rebuilds for the same user (the second + * caller awaits the first) — bursts of events for the same address don't + * fan out into duplicated RPC traffic. + */ + rebuild(user: Address): Promise { + const inflight = this.inflightRebuilds.get(user); + if (inflight !== undefined) return inflight; + const promise = this.doRebuild(user).finally(() => { + this.inflightRebuilds.delete(user); + }); + this.inflightRebuilds.set(user, promise); + return promise; + } + + /** Total users currently indexed (i.e. predicted to be liquidatable somewhere). */ + size(): number { + return this.liqIndex.size(); + } + + /** Total users with active warn-level predictive thresholds. */ + warnSize(): number { + return this.warnIndex.size(); + } + + /** Total users with active critical-level predictive thresholds. */ + critSize(): number { + return this.critIndex.size(); + } + + /** In-flight rebuild count — useful for healthcheck and shutdown ordering. */ + inflight(): number { + return this.inflightRebuilds.size; + } + + /** + * Await all currently in-flight rebuilds. Used at startup so we can + * declare "ready" only after the startup backfill has populated + * the index. New rebuilds queued *after* this snapshot of inflight + * promises will not block the returned promise — that's intentional; + * callers should re-call if they want to drain a steady-state stream. + */ + async awaitIdle(): Promise { + const pending = Array.from(this.inflightRebuilds.values()); + if (pending.length === 0) return; + await Promise.allSettled(pending); + } + + private async doRebuild(user: Address): Promise { + if (this.params === undefined) return; + const current = this.priceFeed.current(); + if (current === undefined) { + this.logger.debug({ user }, "rebuild deferred — priceFeed has no value yet"); + return; + } + try { + const snap = await readAccountSnapshot(this.chain, this.config, user); + const liq = solveLiquidationThresholds(snap, this.params, current); + const liqTracked = this.liqIndex.upsert(liq); + + // Alert thresholds only matter when we have a notifier wired AND the + // user has collateral. ppm scaling matches `computeUtilization` in + // `pme/health.ts`, which truncates to 6 decimal digits. + let warnTracked = false; + let critTracked = false; + if (this.notifier !== undefined && snap.balance > 0n) { + const warnPpm = BigInt(Math.round(this.config.alerts.imWarnUtilization * 1_000_000)); + const critPpm = BigInt(Math.round(this.config.alerts.imCriticalUtilization * 1_000_000)); + const alerts = solveAlertThresholds(snap, this.params, current, warnPpm, critPpm); + warnTracked = this.warnIndex.upsert({ + user: alerts.user, + liqDown: alerts.warnDown, + liqUp: alerts.warnUp, + }); + critTracked = this.critIndex.upsert({ + user: alerts.user, + liqDown: alerts.critDown, + liqUp: alerts.critUp, + }); + } else { + // Make sure stale entries are dropped if the notifier is unwired + // mid-flight or balance went to zero. + this.warnIndex.invalidate(user); + this.critIndex.invalidate(user); + } + + this.logger.debug( + { + user, + liqDown: liq.liqDown, + liqUp: liq.liqUp, + liqTracked, + warnTracked, + critTracked, + }, + "predictive snapshot rebuilt", + ); + } catch (err) { + this.logger.error({ err, user }, "rebuild failed — leaving prior thresholds in place"); + } + } + + private handlePriceUpdate(update: PriceUpdate): void { + const { prev, next } = update; + if (prev === undefined) return; + + if (this.config.oracle.priceMoveTriggerBps > 0) { + const moveBps = absDelta(prev, next); + if (moveBps < this.config.oracle.priceMoveTriggerBps) { + this.logger.debug({ prev, next, moveBps }, "price move below trigger threshold — skipping"); + return; + } + } + + const liqCrossings = this.liqIndex.crossings(prev, next); + const warnCrossings = this.warnIndex.crossings(prev, next); + const critCrossings = this.critIndex.crossings(prev, next); + + if (liqCrossings.length + warnCrossings.length + critCrossings.length === 0) return; + + this.logger.info( + { + prev, + next, + liq: liqCrossings.length, + warn: warnCrossings.length, + crit: critCrossings.length, + }, + "price crossed predictive thresholds", + ); + + // All three paths need the same fresh AccountHealth read, so we + // dedupe the union and read once. Crit users dominate — they get + // both alerts AND queue treatment. Warn users skip the queue path. + const allUsers = Array.from( + new Set([ + ...liqCrossings.map((c) => c.user), + ...warnCrossings.map((c) => c.user), + ...critCrossings.map((c) => c.user), + ]), + ); + const liqUsers = new Set(liqCrossings.map((c) => c.user)); + const warnUsers = new Set(warnCrossings.map((c) => c.user)); + const critUsers = new Set(critCrossings.map((c) => c.user)); + + void this.handleCrossings(allUsers, liqUsers, warnUsers, critUsers); + } + + /** + * Handle a batch of crossings: read each user's current on-chain health + * (one multicall), then route: + * - liq crossings → queue.upsert + executor.kick + * - warn crossings → notifier.enqueue("warn") if not already at crit + * - crit crossings → notifier.enqueue("critical") + * + * Always rebuild after evaluation so stale thresholds get refreshed + * against the new spot. + */ + private async handleCrossings( + allUsers: Address[], + liqUsers: Set
, + warnUsers: Set
, + critUsers: Set
, + ): Promise { + try { + const healths = await readAccountHealthBatch(this.chain, this.config, allUsers); + let enqueued = 0; + let alertsFired = 0; + for (const h of healths) { + if (liqUsers.has(h.user)) { + if (this.queue.upsert(h)) enqueued++; + } + if (this.notifier !== undefined) { + // Critical wins over warn for the same user — fire the higher + // severity only. The notifier dedupes per (severity, user). + if ( + critUsers.has(h.user) && + h.imUtilization >= this.config.alerts.imCriticalUtilization + ) { + this.notifier.enqueue({ + severity: "critical", + user: h.user, + health: h, + reason: `predictive: IM utilization ${(h.imUtilization * 100).toFixed(1)}% ≥ critical ${(this.config.alerts.imCriticalUtilization * 100).toFixed(1)}%`, + }); + alertsFired++; + } else if ( + warnUsers.has(h.user) && + h.imUtilization >= this.config.alerts.imWarnUtilization + ) { + this.notifier.enqueue({ + severity: "warn", + user: h.user, + health: h, + reason: `predictive: IM utilization ${(h.imUtilization * 100).toFixed(1)}% ≥ warn ${(this.config.alerts.imWarnUtilization * 100).toFixed(1)}%`, + }); + alertsFired++; + } + } + } + if (enqueued > 0) { + this.logger.info({ enqueued, evaluated: healths.length }, "predictive enqueue"); + this.executor.kick(); + } + if (alertsFired > 0) { + this.logger.info({ alertsFired, evaluated: healths.length }, "predictive alerts queued"); + // Drain immediately — the sweep could be 60s away. Fire-and-forget; + // any failures re-buffer themselves at the head. + if (this.notifier !== undefined) void this.notifier.drain(); + } + for (const user of allUsers) void this.rebuild(user); + } catch (err) { + this.logger.error({ err, users: allUsers.length }, "handleCrossings failed"); + } + } +} + +/** + * Absolute price-move magnitude in basis points (1bp = 0.01%). Computed + * relative to `prev` — "how much did the price move as a fraction of where + * it was". Returns 0 when `prev === 0n`. + */ +function absDelta(prev: bigint, next: bigint): number { + if (prev === 0n) return 0; + const diff = next > prev ? next - prev : prev - next; + return Number((diff * 10_000n) / prev); +} diff --git a/keeper/src/predict/mm.ts b/keeper/src/predict/mm.ts new file mode 100644 index 0000000..5b56c97 --- /dev/null +++ b/keeper/src/predict/mm.ts @@ -0,0 +1,157 @@ +import type { AccountSnapshot, MMParams } from "./types.ts"; + +/** + * Off-chain replica of `PortfolioMarginEngine._computeMargin`, restricted to + * the pure-delta case (no options Greeks). The contract's stress engine is: + * + * netDelta = perpDelta + futuresDelta + * = (perpNetQty * WAD / 10^perpQtyDecimals) + getNetPositionDelta() + * + * stressLossWad = max over 4 (±spotShock, ±volShock) scenarios of + * max(0, -(netDelta * deltaS / WAD + ½γ(deltaS)² + ν * deltaVol)) + * + * For our pure-delta portfolios (γ=ν=0), the worst scenario is the one where + * `deltaS` opposes `netDelta`, giving `|netDelta| * spotShock * P / WAD²` + * in WAD. We then rescale to token decimals exactly the way `_fromWad` does. + * + * Plus the four constant or P-piecewise-linear add-ons: + * - perp.orderMargin (constant) + * - perp.unrealizedLoss = max(0, -((P - entry) * netQty / qtyScale)) + * - futures.orderMargin (constant) + * - futures.unrealizedLoss = sum_i max(0, -(buyer? : ±)(P - entry_i)*deliveryDays) + * - perp.fundingOwed (constant — short-term, refreshed on snapshot) + * + * Total mmRequired(P) is therefore piecewise-linear with kinks at the + * per-leg break-even prices. We deliberately keep the math straight (no + * over-engineered piecewise representation) — `mmRequired` is cheap, the + * solver bisects when it matters, and the closed-form solver invokes this + * to verify its candidate roots. + * + * All bigint arithmetic. Token-decimal rounding matches PME's integer division. + */ + +const WAD = 10n ** 18n; + +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** + * Aggregate net delta in WAD (matches `_aggregateGreeks` for pure-delta). + * + * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals + * futuresDelta = sum_i (isBuyer ? +1 : -1) * deliveryDays * WAD + * + * Note: the on-chain `getNetPositionDelta` already returns this sum for the + * futures leg in WAD; we re-derive it here off-chain because the snapshot + * carries per-position rows (needed for per-leg PnL kinks) and re-using + * them avoids a second contract call. Both paths converge on the same value. + */ +export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + let delta = (snap.perp.netQty * WAD) / perpQtyScale; + for (const pos of snap.futures.positions) { + const sign = pos.isBuyer ? 1n : -1n; + delta += sign * snap.futures.deliveryDays * WAD; + } + return delta; +} + +/** + * Scale a WAD-denominated value down to token decimals using PME's exact + * convention (integer division by `10^(18 - tokenDecimals)`). + */ +function fromWad(wadValue: bigint, tokenDecimals: number): bigint { + return wadValue / 10n ** BigInt(18 - tokenDecimals); +} + +/** + * Stress loss in token decimals. Pure-delta worst case: + * + * |delta| * shock * P_wad / WAD² (in WAD) + * + * where P_wad = P_token * 10^(18 - tokenDecimals). + * + * Equivalent to the on-chain 4-scenario max in the absence of γ and ν. + */ +export function stressLoss( + delta: bigint, + shock: bigint, + P: bigint, + tokenDecimals: number, +): bigint { + const Pwad = P * 10n ** BigInt(18 - tokenDecimals); + const stressWad = (abs(delta) * shock * Pwad) / (WAD * WAD); + return fromWad(stressWad, tokenDecimals); +} + +/** + * Perp unrealized loss at price P. + * + * pnl = (P - entry) * netQty / 10^perpQtyDecimals + * loss = max(0, -pnl) + */ +export function perpUnrealizedLoss(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + if (snap.perp.netQty === 0n) return 0n; + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + const pnl = ((P - snap.perp.entryPrice) * snap.perp.netQty) / perpQtyScale; + return pnl < 0n ? -pnl : 0n; +} + +/** + * Sum of per-position futures unrealized losses at price P. Each contract: + * + * diffPerDay = isBuyer ? (P - entryPerDay) : (entryPerDay - P) + * pnl = diffPerDay * deliveryDays + * loss = max(0, -pnl) + */ +export function futuresUnrealizedLoss(snap: AccountSnapshot, P: bigint): bigint { + let sum = 0n; + for (const pos of snap.futures.positions) { + const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; + const pnl = diffPerDay * snap.futures.deliveryDays; + if (pnl < 0n) sum += -pnl; + } + return sum; +} + +/** + * Maintenance-margin requirement at price P. Mirrors PME's + * `_computeMargin(user, isIM=false)` for pure-delta portfolios. + */ +export function mmRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + const delta = netDeltaWad(snap, params); + return ( + stressLoss(delta, params.mmSpotShock, P, params.tokenDecimals) + + snap.perp.orderMargin + + snap.futures.orderMargin + + perpUnrealizedLoss(snap, params, P) + + futuresUnrealizedLoss(snap, P) + + snap.perp.fundingOwed + ); +} + +/** + * Initial-margin requirement at price P. Same shape, swap mmShock → imShock. + */ +export function imRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + const delta = netDeltaWad(snap, params); + return ( + stressLoss(delta, params.imSpotShock, P, params.tokenDecimals) + + snap.perp.orderMargin + + snap.futures.orderMargin + + perpUnrealizedLoss(snap, params, P) + + futuresUnrealizedLoss(snap, P) + + snap.perp.fundingOwed + ); +} + +/** `balance - mmRequired(P)`. Negative = liquidatable. */ +export function mmSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return snap.balance - mmRequired(snap, params, P); +} + +/** `balance - imRequired(P)`. Negative = below IM (warn / critical band). */ +export function imSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return snap.balance - imRequired(snap, params, P); +} diff --git a/keeper/src/predict/predictiveIndex.ts b/keeper/src/predict/predictiveIndex.ts new file mode 100644 index 0000000..0234d57 --- /dev/null +++ b/keeper/src/predict/predictiveIndex.ts @@ -0,0 +1,172 @@ +import type { Address } from "viem"; +import type { PriceThresholds } from "./types.ts"; + +/** + * Crossings emitted by `PredictiveIndex.crossings(prev, next)`. The + * coordinator translates each crossing into a fresh on-chain health read + + * `CoordinatorQueue.upsert`. + */ +export interface Crossing { + user: Address; + /** Threshold price that was crossed. */ + threshold: bigint; + /** "down": triggered when spot dropped below threshold (net-long users). */ + direction: "down" | "up"; +} + +/** + * Per-user predicted liquidation thresholds, indexed for fast crossing + * detection on every price tick. + * + * Stores two views of the same `PriceThresholds` data: + * + * - `byUser`: keyed lookup for upsert/invalidate. + * - `downSorted` / `upSorted`: parallel sorted arrays for O(log N + K) + * per-tick crossing lookup, where K is the number of users actually + * crossed by this tick. + * + * Invariants: + * - `byUser.get(addr).liqDown` (when defined) appears exactly once in + * `downSorted`. Same for `liqUp` ↔ `upSorted`. + * - `downSorted` is sorted ASC by `threshold`. `upSorted` is sorted ASC + * by `threshold`. Both choices give us O(log) binary search for the + * range of crossings on either side. + * + * We deliberately keep both arrays as plain `[]` and re-sort on insert. + * For our scale (≤ low thousands of underwater-eligible users), this beats + * a balanced-BST library both in code size and constant factor. + */ +export class PredictiveIndex { + private readonly byUser = new Map(); + private downSorted: Array<{ threshold: bigint; user: Address }> = []; + private upSorted: Array<{ threshold: bigint; user: Address }> = []; + + /** + * Insert or update the user's thresholds. Removes any prior entry for + * the same user from both sorted arrays before re-inserting. Returns + * `true` when the user has at least one defined threshold after the call + * (i.e. is "watched"); `false` if they have neither. + */ + upsert(thresholds: PriceThresholds): boolean { + const { user, liqDown, liqUp } = thresholds; + this.removeUser(user); + if (liqDown === undefined && liqUp === undefined) return false; + this.byUser.set(user, thresholds); + if (liqDown !== undefined) { + insertSorted(this.downSorted, { threshold: liqDown, user }); + } + if (liqUp !== undefined) { + insertSorted(this.upSorted, { threshold: liqUp, user }); + } + return true; + } + + /** Remove a user from the index. Idempotent. */ + invalidate(user: Address): void { + this.removeUser(user); + } + + /** Lookup the cached thresholds for a user (or `undefined` if untracked). */ + get(user: Address): PriceThresholds | undefined { + return this.byUser.get(user); + } + + /** Number of users with at least one defined threshold. */ + size(): number { + return this.byUser.size; + } + + /** + * Find every user whose threshold was crossed by a price move from + * `prev` to `next`. Both endpoints are inclusive of the boundary — + * landing exactly on a threshold counts as a crossing because the + * on-chain `mmSurplus < 0` predicate treats that as an edge-case the + * planner should re-verify. + * + * Crossing rules: + * - DOWN-cross fires for users with `liqDown ∈ [next, prev]` when + * the price fell (`next < prev`). + * - UP-cross fires for users with `liqUp ∈ [prev, next]` when the + * price rose (`next > prev`). + * + * `prev = undefined` (first tick after start) returns nothing — we don't + * have a baseline to detect crossings against; the periodic sweep + * catches anything already in the danger zone. + */ + crossings(prev: bigint | undefined, next: bigint): Crossing[] { + if (prev === undefined || next === prev) return []; + const out: Crossing[] = []; + if (next < prev) { + // Falling price: pick downSorted entries with threshold ∈ [next, prev]. + const lo = lowerBound(this.downSorted, next); + const hi = upperBound(this.downSorted, prev); + for (let i = lo; i < hi; i++) { + const entry = this.downSorted[i]; + if (entry === undefined) continue; + out.push({ user: entry.user, threshold: entry.threshold, direction: "down" }); + } + } else { + // Rising price: pick upSorted entries with threshold ∈ [prev, next]. + const lo = lowerBound(this.upSorted, prev); + const hi = upperBound(this.upSorted, next); + for (let i = lo; i < hi; i++) { + const entry = this.upSorted[i]; + if (entry === undefined) continue; + out.push({ user: entry.user, threshold: entry.threshold, direction: "up" }); + } + } + return out; + } + + /** Snapshot of all tracked users' thresholds (test/debug). */ + snapshot(): readonly PriceThresholds[] { + return Array.from(this.byUser.values()); + } + + private removeUser(user: Address): void { + if (!this.byUser.has(user)) return; + this.byUser.delete(user); + this.downSorted = this.downSorted.filter((e) => e.user !== user); + this.upSorted = this.upSorted.filter((e) => e.user !== user); + } +} + +interface SortedEntry { + threshold: bigint; + user: Address; +} + +function insertSorted(arr: SortedEntry[], entry: SortedEntry): void { + // Binary insertion — the arrays grow monotonically with tracked users. + // A real heap is overkill at our scale; sort-on-insert is O(log N) for + // the search and O(N) for the splice, which beats heap ceremony for + // ≤ a few thousand entries. + const idx = lowerBound(arr, entry.threshold); + arr.splice(idx, 0, entry); +} + +/** First index with `arr[i].threshold >= target`. Returns `arr.length` when none. */ +function lowerBound(arr: SortedEntry[], target: bigint): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const entry = arr[mid]; + if (entry === undefined || entry.threshold < target) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** First index with `arr[i].threshold > target`. Returns `arr.length` when none. */ +function upperBound(arr: SortedEntry[], target: bigint): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const entry = arr[mid]; + if (entry === undefined || entry.threshold <= target) lo = mid + 1; + else hi = mid; + } + return lo; +} diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts new file mode 100644 index 0000000..6488353 --- /dev/null +++ b/keeper/src/predict/snapshot.ts @@ -0,0 +1,166 @@ +import type { Address, Hex } from "viem"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { PortfolioMarginEngineAbi } from "collateral-margin/PortfolioMarginEngine.ts"; +import { HashPowerPerpsDEXAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; +import { FuturesAbi } from "futures-marketplace/Futures.ts"; +import type { AccountSnapshot, MMParams } from "./types.ts"; + +/** + * Read the engine-wide constants once. They only change on PME admin + * transactions (`setShocks`), so the predictor caches them for the lifetime + * of the process — there's no periodic re-read; an admin `setShocks` requires + * a keeper restart to pick up. + */ +export async function readMMParams(chain: Chain, config: Config): Promise { + const reads = await chain.publicClient.multicall({ + contracts: [ + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "imSpotShock" as const, + }, + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "mmSpotShock" as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "decimals" as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "QUANTITY_DECIMALS" as const, + }, + ], + allowFailure: false, + }); + + return { + imSpotShock: reads[0] as bigint, + mmSpotShock: reads[1] as bigint, + tokenDecimals: reads[2] as number, + perpQuantityDecimals: reads[3] as number, + }; +} + +/** + * Read everything needed to evaluate `mmSurplus(P)` for a single user as a + * function of price. Two RPC round-trips: + * + * 1. Bulk multicall: balance, perp position/orderMargin/funding, + * futures orderMargin/positionIds, deliveryDurationDays. + * 2. Per-position multicall: hydrate each futures position so we know its + * `(buyer, buyPricePerDay, sellPricePerDay)` for off-chain PnL. + * + * Round-trip 2 collapses to zero calls when the user has no futures + * positions (the common case for perps-only users). + */ +export async function readAccountSnapshot( + chain: Chain, + config: Config, + user: Address, +): Promise { + const [ + balance, + perpPosition, + perpOrderMargin, + perpFunding, + futuresOrderMargin, + futuresPositionIds, + deliveryDurationDays, + ] = await chain.publicClient.multicall({ + contracts: [ + { + address: config.vault.address, + abi: CollateralVaultAbi, + functionName: "balanceOf" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserPosition" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getOrderMargin" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getPendingFunding" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: FuturesAbi, + functionName: "getFuturesOrderMargin" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: FuturesAbi, + functionName: "getPositionIds" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: FuturesAbi, + functionName: "deliveryDurationDays" as const, + }, + ] as const, + allowFailure: false, + }); + + const positionIds = futuresPositionIds as readonly Hex[]; + const futuresPositions: AccountSnapshot["futures"]["positions"] = []; + if (positionIds.length > 0) { + const positions = await chain.publicClient.multicall({ + contracts: positionIds.map((id) => ({ + address: config.futures.address, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + allowFailure: false, + }); + const userLower = user.toLowerCase(); + for (let i = 0; i < positionIds.length; i++) { + const pos = positions[i]; + const id = positionIds[i]; + if (pos === undefined || id === undefined) continue; + const isBuyer = pos.buyer.toLowerCase() === userLower; + futuresPositions.push({ + id, + isBuyer, + entryPricePerDay: isBuyer ? pos.buyPricePerDay : pos.sellPricePerDay, + }); + } + } + + const funding = perpFunding as bigint; + return { + user, + balance: balance as bigint, + perp: { + netQty: perpPosition.netQuantity, + entryPrice: perpPosition.aggregatedEntryPrice, + orderMargin: perpOrderMargin as bigint, + // PME uses `max(0, pendingFunding)` — only what the user owes. + fundingOwed: funding > 0n ? funding : 0n, + }, + futures: { + positions: futuresPositions, + orderMargin: futuresOrderMargin as bigint, + deliveryDays: BigInt(deliveryDurationDays as number), + }, + }; +} diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts new file mode 100644 index 0000000..cd3e673 --- /dev/null +++ b/keeper/src/predict/solve.ts @@ -0,0 +1,218 @@ +import type { AccountSnapshot, AlertThresholds, MMParams, PriceThresholds } from "./types.ts"; +import { imRequired, mmSurplus } from "./mm.ts"; + +/** + * Find the price thresholds where `mmSurplus(P)` crosses zero. + * + * `mmRequired(P)` is piecewise-linear in P with kinks at each leg's + * break-even price (perp entry, each futures position's entry-per-day). + * Stress is `|delta| × shock × P / WAD` after rescaling — strictly + * non-decreasing in P for fixed |delta|. + * + * For a typical net-long portfolio, `mmSurplus(P)` is therefore a tent shape: + * - Climbs as P rises (PnL recovers faster than stress grows) until the + * last losing leg breaks even. + * - Above all break-even prices, only stress contributes — `mmSurplus(P)` + * declines linearly to negative infinity as P → ∞. + * Net-short portfolios mirror this around an inverted apex. + * + * We don't try to derive a single closed form for the general piecewise + * landscape — between leg counts, sign mixes, and stress magnitude vs. + * leverage, the case analysis is fragile. Instead we: + * + * 1. Enumerate the kink prices (perp entry + each futures entry). + * 2. Bisect on each side of the current price (down and up) on intervals + * bounded by adjacent kinks. `mmSurplus(P)` is monotone within each + * interval, so a standard bisection converges in O(log) per interval. + * 3. Return the closest crossings on either side of `currentPrice`. + * + * O(K · log(2^60)) per user where K is the number of kinks (≤ #futures + * positions + 1). At keeper scale (≤ a few positions per user), this is a + * handful of µs of pure CPU work — negligible vs the RPC the snapshot read + * already cost. + */ +export function solveLiquidationThresholds( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, +): PriceThresholds { + // Already underwater → no useful threshold; the caller should liquidate + // immediately rather than wait for a future price tick. + if (mmSurplus(snap, params, currentPrice) < 0n) { + return { user: snap.user, liqDown: undefined, liqUp: undefined }; + } + const result = findClosestCrossings(snap, currentPrice, (P) => mmSurplus(snap, params, P)); + return { user: snap.user, liqDown: result.down, liqUp: result.up }; +} + +/** + * Find the prices at which the user's IM utilization (`imRequired / balance`) + * crosses the warn and critical thresholds. Used by the predictive + * coordinator to fire alerts *before* the next sweep tick discovers them. + * + * For each level we solve `imRequired(P) - level * balance = 0`. Returns + * `undefined` for any side that's never crossed (e.g. a flat user can't be + * pushed into IM-warn by price moves). Already past the threshold at + * `currentPrice` → returns `undefined` for that level (the sweep-driven + * alert path will catch it on the next tick). + */ +export function solveAlertThresholds( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + warnUtilizationPpm: bigint, + criticalUtilizationPpm: bigint, +): AlertThresholds { + // No collateral → no IM utilization is well-defined; sweep handles it. + if (snap.balance <= 0n) { + return { + user: snap.user, + warnDown: undefined, + warnUp: undefined, + critDown: undefined, + critUp: undefined, + }; + } + // Target ppm scaling: imRequired - util * balance = imRequired - (utilPpm * balance) / 1e6 + const PPM = 1_000_000n; + const warnTarget = (warnUtilizationPpm * snap.balance) / PPM; + const critTarget = (criticalUtilizationPpm * snap.balance) / PPM; + const f = (target: bigint) => (P: bigint) => imRequired(snap, params, P) - target; + + // For an alert level we want price points where `imRequired(P) = target`. + // Already at-or-over the target at currentPrice → that level isn't a + // forward-looking trigger; the sweep alert path will fire it. + const warn = + imRequired(snap, params, currentPrice) >= warnTarget + ? { down: undefined, up: undefined } + : findClosestCrossings(snap, currentPrice, f(warnTarget)); + const crit = + imRequired(snap, params, currentPrice) >= critTarget + ? { down: undefined, up: undefined } + : findClosestCrossings(snap, currentPrice, f(critTarget)); + return { + user: snap.user, + warnDown: warn.down, + warnUp: warn.up, + critDown: crit.down, + critUp: crit.up, + }; +} + +/** + * Generic: find the closest prices on either side of `currentPrice` where + * the supplied `f` function crosses zero. Uses the same kink-driven + * piecewise-monotone bisection as `solveLiquidationThresholds`, parameterised + * so multiple solvers (liq, im-warn, im-crit) can share the engine. + * + * Sign-convention agnostic: detects crossings regardless of which sign + * means "safe". Callers are responsible for short-circuiting when + * currentPrice is already past the threshold of interest. + */ +function findClosestCrossings( + snap: AccountSnapshot, + currentPrice: bigint, + f: (P: bigint) => bigint, +): { down: bigint | undefined; up: bigint | undefined } { + const kinks: bigint[] = []; + if (snap.perp.netQty !== 0n) kinks.push(snap.perp.entryPrice); + for (const pos of snap.futures.positions) { + kinks.push(pos.entryPricePerDay); + } + kinks.push(currentPrice); + kinks.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const dedup: bigint[] = []; + for (const k of kinks) { + if (dedup.length === 0 || dedup[dedup.length - 1] !== k) dedup.push(k); + } + + const lastDedup = dedup[dedup.length - 1] ?? currentPrice; + const upperCap = lastDedup * 1024n + 1n; + const lowerCap = 1n; + + const intervals: Array<[bigint, bigint]> = []; + let prev = lowerCap; + for (const k of dedup) { + if (k > prev) intervals.push([prev, k]); + prev = k; + } + if (upperCap > prev) intervals.push([prev, upperCap]); + + let down: bigint | undefined; + let up: bigint | undefined; + + for (const [lo, hi] of intervals) { + const sLo = f(lo); + const sHi = f(hi); + if ((sLo > 0n && sHi > 0n) || (sLo < 0n && sHi < 0n)) continue; + if (sLo === 0n) { + registerCrossing(lo, currentPrice, (isDown) => { + if (isDown) down = closer(down, lo, currentPrice, true); + else up = closer(up, lo, currentPrice, false); + }); + continue; + } + if (sHi === 0n) { + registerCrossing(hi, currentPrice, (isDown) => { + if (isDown) down = closer(down, hi, currentPrice, true); + else up = closer(up, hi, currentPrice, false); + }); + continue; + } + const root = bisect(lo, hi, sLo, f); + if (root < currentPrice) down = closer(down, root, currentPrice, true); + else if (root > currentPrice) up = closer(up, root, currentPrice, false); + } + + return { down, up }; +} + +/** Bisect within [lo, hi] until the interval shrinks to 1 wei. Assumes a sign change. */ +function bisect( + lo: bigint, + hi: bigint, + sLo: bigint, + f: (P: bigint) => bigint, +): bigint { + let a = lo; + let b = hi; + let sa = sLo; + // Conservative iteration cap: for any 256-bit price the interval halves + // 256 times before becoming 1 wei. We never actually reach that — we exit + // on the (b - a) <= 1 condition first. + for (let i = 0; i < 256; i++) { + if (b - a <= 1n) return sa < 0n ? b : a; + const mid = (a + b) / 2n; + const sm = f(mid); + if (sm === 0n) return mid; + // Maintain invariant: sa and sb have opposite signs. + if ((sa < 0n && sm < 0n) || (sa > 0n && sm > 0n)) { + a = mid; + sa = sm; + } else { + b = mid; + } + } + return a; +} + +function registerCrossing(at: bigint, currentPrice: bigint, sink: (down: boolean) => void): void { + if (at < currentPrice) sink(true); + else if (at > currentPrice) sink(false); +} + +/** + * Pick whichever candidate threshold is *closer* to `currentPrice`. For the + * downside ("liquidatable when spot falls below"), closer means the one + * with the higher price; for the upside, the one with the lower price. + */ +function closer( + prev: bigint | undefined, + candidate: bigint, + _currentPrice: bigint, + isDown: boolean, +): bigint { + if (prev === undefined) return candidate; + if (isDown) return candidate > prev ? candidate : prev; + return candidate < prev ? candidate : prev; +} diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts new file mode 100644 index 0000000..88fb9f3 --- /dev/null +++ b/keeper/src/predict/types.ts @@ -0,0 +1,92 @@ +import type { Address, Hex } from "viem"; + +/** + * Per-account inputs needed to evaluate `mmRequired(P)` and `imRequired(P)` + * off-chain at an arbitrary spot price `P`. Captured as a snapshot so the + * predictor can re-evaluate at any new price without further RPC reads. + * + * Shapes deliberately mirror the on-chain getters: + * - perps: `getUserPosition` + `getOrderMargin` + `getPendingFunding` + * - futures: `getPositionIds`/`getPositionById` + `getFuturesOrderMargin` + * + * Bigints throughout because PME math is performed in token-decimal units + * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers + * lose precision at the dollar level for typical position sizes. + */ +export interface AccountSnapshot { + user: Address; + /** Vault balance (token decimals). */ + balance: bigint; + + /** Perps single netted position (zero-qty if user has no perp exposure). */ + perp: { + /** Signed; +long, −short. Scaled by 10^perpQuantityDecimals. */ + netQty: bigint; + /** Token decimals (matches `getMarketPrice`). */ + entryPrice: bigint; + /** Constant in P: `getOrderMargin(user)` (token decimals). */ + orderMargin: bigint; + /** `max(0, getPendingFunding(user))` snapshot (token decimals). */ + fundingOwed: bigint; + }; + + /** + * One entry per active futures position. Each contract is a single unit; + * PnL accrues `(P_perDay - entryPricePerDay) × deliveryDays` from the + * holder's perspective (`+` for buyers, `−` for sellers). + */ + futures: { + positions: Array<{ + id: Hex; + isBuyer: boolean; + /** Token decimals. */ + entryPricePerDay: bigint; + }>; + /** Constant in P: `getFuturesOrderMargin(user)`. */ + orderMargin: bigint; + /** Same delivery duration applies to every active position. */ + deliveryDays: bigint; + }; +} + +/** + * Engine-wide constants needed by the off-chain MM math. Read once during + * snapshot setup and cached — they only change on PME admin transactions. + */ +export interface MMParams { + /** WAD-scaled (e.g. 0.05e18 = 5%). */ + imSpotShock: bigint; + /** WAD-scaled (e.g. 0.10e18 = 10%). */ + mmSpotShock: bigint; + /** Decimals of the venues' answer (USDC = 6). */ + tokenDecimals: number; + /** Perps quantity decimals (typically 6). */ + perpQuantityDecimals: number; +} + +/** + * Per-account price thresholds derived from the snapshot. `undefined` means + * the user is structurally not liquidatable on that side (e.g. flat or + * already deeply healthy at any plausible price). + */ +export interface PriceThresholds { + user: Address; + /** Liquidatable when spot drops to or below this. */ + liqDown: bigint | undefined; + /** Liquidatable when spot rises to or above this. */ + liqUp: bigint | undefined; +} + +/** + * Per-account IM-utilization alert thresholds. Same {down, up} pattern as + * `PriceThresholds`, just one set per severity. `undefined` on a level + * means the user is already over (or structurally cannot reach) that + * level — the sweep alert path covers the "already over" case. + */ +export interface AlertThresholds { + user: Address; + warnDown: bigint | undefined; + warnUp: bigint | undefined; + critDown: bigint | undefined; + critUp: bigint | undefined; +} diff --git a/keeper/src/runtime/healthcheck.ts b/keeper/src/runtime/healthcheck.ts new file mode 100644 index 0000000..f1de857 --- /dev/null +++ b/keeper/src/runtime/healthcheck.ts @@ -0,0 +1,128 @@ +import { createServer, type Server } from "node:http"; +import type pino from "pino"; +import type { Config } from "../config.ts"; +import type { CoordinatorExecutor } from "../coordinator/executor.ts"; +import type { CoordinatorQueue } from "../coordinator/queue.ts"; +import type { ParticipantTracker } from "../discovery/tracker.ts"; +import type { PriceFeed } from "../oracle/priceFeed.ts"; +import type { PredictiveCoordinator } from "../predict/coordinator.ts"; + +/** + * Health and metrics surface for the keeper. + * + * GET /health liveness probe (200 ok / 503 degraded). Body holds the + * same metrics as /metrics for convenience. + * GET /metrics Prometheus-text exposition of keeper-internal counters. + * + * Health flips to 503 when the executor isn't running (event watcher + * silently dropped, executor stopped) so the orchestrator (k8s, ECS) + * restarts the pod. Metrics are exposed unconditionally — useful even + * when the keeper is degraded. + * + * Predictor metrics are optional so this module remains usable for the + * legacy boot path that doesn't have one. + */ +export class Healthcheck { + private server: Server | undefined; + + private readonly config: Config; + private readonly tracker: ParticipantTracker; + private readonly executor: CoordinatorExecutor; + private readonly queue: CoordinatorQueue; + private readonly predictor: PredictiveCoordinator | undefined; + private readonly priceFeed: PriceFeed | undefined; + private readonly logger: pino.Logger; + + constructor( + config: Config, + tracker: ParticipantTracker, + executor: CoordinatorExecutor, + queue: CoordinatorQueue, + logger: pino.Logger, + predictor?: PredictiveCoordinator, + priceFeed?: PriceFeed, + ) { + this.config = config; + this.tracker = tracker; + this.executor = executor; + this.queue = queue; + this.predictor = predictor; + this.priceFeed = priceFeed; + this.logger = logger.child({ component: "healthcheck" }); + } + + /** Snapshot of every observable counter the keeper exposes. */ + snapshot(): Record { + return { + executorRunning: this.executor.isRunning() ? 1 : 0, + trackedUsers: this.tracker.size(), + inflight: this.executor.inflightCount(), + queueDepth: this.queue.size(), + predictedUsers: this.predictor?.size() ?? 0, + predictedWarnUsers: this.predictor?.warnSize() ?? 0, + predictedCritUsers: this.predictor?.critSize() ?? 0, + predictorInflight: this.predictor?.inflight() ?? 0, + currentPrice: this.priceFeed?.current()?.toString() ?? null, + }; + } + + start(): void { + this.server = createServer((req, res) => { + if (req.url === "/health") { + const ok = this.executor.isRunning(); + res.writeHead(ok ? 200 : 503, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + status: ok ? "ok" : "degraded", + ...this.snapshot(), + }), + ); + return; + } + if (req.url === "/metrics") { + res.writeHead(200, { "content-type": "text/plain; version=0.0.4" }); + res.end(this.renderPrometheus()); + return; + } + res.writeHead(404).end(); + }); + + this.server.listen(this.config.runtime.healthPort, () => { + this.logger.info({ port: this.config.runtime.healthPort }, "healthcheck listening"); + }); + } + + async stop(): Promise { + if (this.server === undefined) return; + const srv = this.server; + this.server = undefined; + await new Promise((resolve) => srv.close(() => resolve())); + } + + /** + * Minimal Prometheus exposition. Skips the `currentPrice` line when the + * feed hasn't primed yet (Prometheus rejects non-numeric values). Each + * metric uses a `keeper_` prefix to namespace it from system metrics. + */ + private renderPrometheus(): string { + const snap = this.snapshot(); + const lines: string[] = []; + for (const [k, v] of Object.entries(snap)) { + if (k === "currentPrice") { + if (v === null) continue; + lines.push(`# HELP keeper_oracle_price_token Latest oracle price in token decimals.`); + lines.push(`# TYPE keeper_oracle_price_token gauge`); + lines.push(`keeper_oracle_price_token ${v}`); + continue; + } + const metric = `keeper_${snakeCase(k)}`; + lines.push(`# TYPE ${metric} gauge`); + lines.push(`${metric} ${v}`); + } + return `${lines.join("\n")}\n`; + } +} + +function snakeCase(camel: string): string { + return camel.replace(/([A-Z])/g, "_$1").toLowerCase(); +} diff --git a/keeper/src/runtime/scheduler.ts b/keeper/src/runtime/scheduler.ts new file mode 100644 index 0000000..a8de45b --- /dev/null +++ b/keeper/src/runtime/scheduler.ts @@ -0,0 +1,132 @@ +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import type { CoordinatorQueue } from "../coordinator/queue.ts"; +import type { CoordinatorExecutor } from "../coordinator/executor.ts"; +import type { Notifier } from "../alert/notifier.ts"; +import type { ParticipantTracker } from "../discovery/tracker.ts"; +import { readAccountHealthBatch } from "../pme/health.ts"; + +/** + * Periodic sweep: rebuilds the coordinator queue from the tracker's known + * users by reading their portfolio health in batches via the PME multicall + * (see `pme/health.ts`). + * + * Acts as a safety net on top of the event-driven path — handles dropped + * events, missed webhooks, and price moves that don't trigger any direct + * contract event (the most common gap in our coverage). + * + * Participant discovery is handled separately: live by + * `ParticipantTracker.start()`'s event subscriptions, and at boot by a + * one-shot `tracker.backfill(fromBlock)` from `index.ts`. The scheduler + * no longer owns a periodic tracker refresh — it only re-evaluates the + * users the tracker has already accepted. + * + * The single timer is pure additive — it never blocks the event-driven + * hot path. + */ +export class Scheduler { + private sweepTimer: NodeJS.Timeout | undefined; + private inflightSweep = false; + + private readonly chain: Chain; + private readonly config: Config; + private readonly tracker: ParticipantTracker; + private readonly queue: CoordinatorQueue; + private readonly executor: CoordinatorExecutor; + private readonly notifier: Notifier; + private readonly logger: pino.Logger; + + constructor( + chain: Chain, + config: Config, + tracker: ParticipantTracker, + queue: CoordinatorQueue, + executor: CoordinatorExecutor, + notifier: Notifier, + logger: pino.Logger, + ) { + this.chain = chain; + this.config = config; + this.tracker = tracker; + this.queue = queue; + this.executor = executor; + this.notifier = notifier; + this.logger = logger.child({ component: "scheduler" }); + } + + start(): void { + this.sweepTimer = setInterval(() => { + void this.runSweep(); + }, this.config.runtime.sweepIntervalMs); + this.logger.info( + { sweepMs: this.config.runtime.sweepIntervalMs }, + "scheduler started", + ); + } + + stop(): void { + if (this.sweepTimer !== undefined) { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + } + } + + /** + * Public for tests — runs a single sweep cycle to completion. Idempotent + * even if a previous tick is still in flight (we just skip). + */ + async runSweep(): Promise { + if (this.inflightSweep) { + this.logger.debug("sweep skipped — previous sweep still running"); + return; + } + this.inflightSweep = true; + try { + const users = this.tracker.list(); + if (users.length === 0) return; + + const healths = await readAccountHealthBatch(this.chain, this.config, users); + let underwater = 0; + let warned = 0; + let critical = 0; + for (const h of healths) { + // The queue gates on `mmSurplus < 0` internally — healthy snapshots + // remove the user from the queue, underwater snapshots re-rank it. + this.queue.upsert(h); + if (h.mmSurplus < 0n) underwater++; + + // Alert ladder: critical first (always), then warn unless promoted. + if (h.imUtilization >= this.config.alerts.imCriticalUtilization) { + critical++; + this.notifier.enqueue({ + severity: "critical", + user: h.user, + health: h, + reason: `IM utilization ${(h.imUtilization * 100).toFixed(1)}% ≥ critical ${(this.config.alerts.imCriticalUtilization * 100).toFixed(1)}%`, + }); + } else if (h.imUtilization >= this.config.alerts.imWarnUtilization) { + warned++; + this.notifier.enqueue({ + severity: "warn", + user: h.user, + health: h, + reason: `IM utilization ${(h.imUtilization * 100).toFixed(1)}% ≥ warn ${(this.config.alerts.imWarnUtilization * 100).toFixed(1)}%`, + }); + } + } + + this.logger.debug( + { tracked: users.length, underwater, warned, critical }, + "sweep complete", + ); + + if (underwater > 0) this.executor.kick(); + await this.notifier.drain(); + } catch (err) { + this.logger.error({ err }, "sweep failed"); + } finally { + this.inflightSweep = false; + } + } +} diff --git a/keeper/src/tx/liquidate.ts b/keeper/src/tx/liquidate.ts new file mode 100644 index 0000000..f1bcb62 --- /dev/null +++ b/keeper/src/tx/liquidate.ts @@ -0,0 +1,163 @@ +import { + BaseError, + ContractFunctionRevertedError, + parseEventLogs, + type Abi, + type Address, + type Hex, + type TransactionReceipt, +} from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; + +/** + * Common shape returned by all liquidate-style calls. Either we earned a fee + * (positive on success, possibly 0n when the contract caps it at the user's + * remaining balance), or we hit a known recoverable revert and surface it as + * a `skipped` reason for the planner. + */ +export type LiquidateOutcome = + | { feeEarned: bigint; receipt: TransactionReceipt | null } + | { skipped: S }; + +/** Reverts we treat as recoverable (planner re-plans rather than crashing). */ +type KnownRevert = + | "NotLiquidatable" + | "OrdersStillOpen" + | "OrderNotBelongToUser" + | "OrderNotBelongToParticipant" + | "PositionNotBelongToParticipant" + | "PositionNotExists"; + +const RECOVERABLE_REVERTS = new Set([ + "NotLiquidatable", + "OrdersStillOpen", + "OrderNotBelongToUser", + "OrderNotBelongToParticipant", + "PositionNotBelongToParticipant", + "PositionNotExists", +]); + +interface SendLiquidateOptions { + chain: Chain; + config: Config; + logger: pino.Logger; + address: Address; + abi: Abi; + functionName: string; + args: readonly unknown[]; + /** + * Event name on the supplied ABI whose `fee` (or `liquidatorFee`) field is + * summed across the receipt to produce `feeEarned`. Pass `null` when no fee + * is paid (e.g. an order-only liquidation that earns nothing per leg). + */ + feeEventName: string | null; + /** + * Maps a recoverable revert's `errorName` onto the venue-specific skip + * reason. Unmapped recoverable reverts are still surfaced as `{ skipped }` + * — defaults to "notLiquidatable" so the planner keeps moving. + */ + mapSkip?: (errorName: KnownRevert) => S; +} + +/** + * Simulates a liquidate-style call, sends it (unless `dryRun` is on), and + * extracts `feeEarned` from the receipt. Recoverable reverts surface as + * `{ skipped }` — anything else throws. + * + * Splitting "what to call" (caller) from "how to send + parse + decode + * reverts" (this helper) keeps the venue adapters short and uniform. The + * helper takes a runtime `Abi` (not a generic) — viem's `simulateContract` + * overloads require literal-narrowed function names to typecheck cleanly, + * which we can't provide for arbitrary callers; the caller is responsible for + * making sure `functionName`/`args`/`feeEventName` match the supplied `abi`. + */ +export async function sendLiquidate( + opts: SendLiquidateOptions, +): Promise> { + const { chain, config, logger, address, abi, functionName, args, feeEventName, mapSkip } = opts; + + // Always simulate first — this is how we surface the recoverable reverts + // before we burn gas on a tx that can't possibly succeed. Viem's overloads + // need literal abi inference to typecheck the request shape, so we cast at + // the boundary; the runtime ABI is still validated by viem internally. + type SimParams = Parameters[0]; + type SimReturn = Awaited>; + let request: SimReturn["request"]; + try { + const sim = (await chain.publicClient.simulateContract({ + address, + abi, + functionName, + args, + account: chain.account, + } as unknown as SimParams)) as SimReturn; + request = sim.request; + } catch (err) { + const decoded = decodeRecoverableRevert(err); + if (decoded) { + logger.debug({ functionName, args, revert: decoded }, "Liquidate skipped (recoverable revert)"); + return { + skipped: (mapSkip ? mapSkip(decoded) : ("notLiquidatable" as unknown as S)) as S, + }; + } + throw err; + } + + if (config.keeper.dryRun) { + logger.info({ functionName, args }, "[dryRun] would send liquidate tx"); + return { feeEarned: 0n, receipt: null }; + } + + type WriteParams = Parameters[0]; + const hash = await chain.walletClient.writeContract(request as unknown as WriteParams); + const receipt = await chain.publicClient.waitForTransactionReceipt({ + hash, + confirmations: config.coordinator.confirmationBlocks, + }); + + const feeEarned = feeEventName === null ? 0n : sumFees(abi, receipt, feeEventName); + logger.info({ functionName, args, hash, feeEarned }, "Liquidate tx confirmed"); + return { feeEarned, receipt }; +} + +/** + * Walks viem's nested error chain looking for a `ContractFunctionRevertedError` + * whose `errorName` matches one of the keeper's recoverable reverts. + * Returns `undefined` for any other failure (RPC errors, unknown custom + * errors, etc.) — those bubble up. + */ +function decodeRecoverableRevert(err: unknown): KnownRevert | 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 as KnownRevert) ? (name as KnownRevert) : undefined; +} + +/** + * Sums the `fee` (or `liquidatorFee`) field across every matching event in the + * receipt. Both venues emit one event per liquidated order/position carrying + * the per-leg fee, so this naturally aggregates batch calls + * (`liquidateOrders` cancels N orders → N events → summed fees). + */ +function sumFees(abi: Abi, receipt: TransactionReceipt, eventName: string): bigint { + const logs = parseEventLogs({ + abi, + logs: receipt.logs, + eventName: eventName as never, + }); + let total = 0n; + for (const log of logs as Array<{ args: Record }>) { + const fee = log.args.fee ?? log.args.liquidatorFee; + if (typeof fee === "bigint") total += fee; + } + return total; +} + +/** Exposed for unit tests so we can assert the planner's revert-handling shape. */ +export const __testing = { decodeRecoverableRevert, sumFees }; + +export type { Hex }; diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts new file mode 100644 index 0000000..073d396 --- /dev/null +++ b/keeper/src/venues/futures.ts @@ -0,0 +1,188 @@ +import { getAddress, pad, toHex, 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/Futures.ts"; +import { sendLiquidate } from "../tx/liquidate.ts"; +import type { + LiquidateOrdersOutcome, + LiquidatePositionOutcome, + MarketId, + Venue, + VenueOrder, + VenuePosition, +} from "./types.ts"; + +/** + * `Venue` adapter for the Futures contract. + * + * Caches `deliveryDurationDays` lazily on first use: the contract setting + * is immutable within an epoch and only ever ratchets on admin action, so + * we read it once per process and re-read after restart. Position PnL math + * uses this value as a multiplier (`priceDiffPerDay * deliveryDurationDays`) + * — caching it keeps `readPositions` to one RPC + one multicall. + */ +export class FuturesVenue implements Venue { + readonly name = "futures" as const; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + private deliveryDurationDays: bigint | undefined; + + constructor(chain: Chain, config: Config, logger: pino.Logger) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ venue: "futures" }); + } + + marketLabel(marketId: MarketId): string { + const deliveryAt = marketIdToDeliveryAt(marketId); + // Render as ISO date so on-call alerts read naturally. + const iso = new Date(Number(deliveryAt) * 1000).toISOString().slice(0, 10); + return `futures ${iso}`; + } + + async readOpenOrders(user: Address): Promise { + const orderIds = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getOrderIds", + args: [user], + })) as readonly Hex[]; + + if (orderIds.length === 0) return []; + + // Hydrate each order so we know its `deliveryAt` (== marketId). The + // contract sweeps FIFO regardless, but the planner wants per-market + // labelling for alerts and ranking. + const orders = await this.chain.publicClient.multicall({ + contracts: orderIds.map((id) => ({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getOrderById" as const, + args: [id] as const, + })), + allowFailure: false, + }); + + return orderIds.map((id, i) => ({ + id, + marketId: deliveryAtMarketId(orders[i].deliveryAt), + })); + } + + async readPositions(user: Address): Promise { + const [positionIds, marketPrice, deliveryDurationDays] = await Promise.all([ + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getPositionIds", + args: [user], + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getMarketPrice", + }) as Promise, + this.getDeliveryDurationDays(), + ]); + + if (positionIds.length === 0) return []; + + const positions = await this.chain.publicClient.multicall({ + contracts: positionIds.map((id) => ({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + allowFailure: false, + }); + + const userAddr = getAddress(user); + return positionIds.map((id, i) => { + const pos = positions[i]; + // Each position is a single contract; PnL accrues per day across the + // full delivery window (matches `getFuturesUnrealizedPnl` on-chain). + const isBuyer = getAddress(pos.buyer) === userAddr; + const entryPricePerDay = isBuyer ? pos.buyPricePerDay : pos.sellPricePerDay; + const priceDiffPerDay = isBuyer + ? marketPrice - entryPricePerDay // long: lose when market drops + : entryPricePerDay - marketPrice; // short: lose when market rises + const pnl = priceDiffPerDay * deliveryDurationDays; + const unrealizedLoss = pnl < 0n ? -pnl : 0n; + const notional = entryPricePerDay * deliveryDurationDays; + + return { + id, + marketId: deliveryAtMarketId(pos.deliveryAt), + unrealizedLoss, + notional, + }; + }); + } + + async liquidateOrders(user: Address, _ids?: readonly Hex[]): Promise { + // Futures sweeps FIFO until the participant is healthy — no calldata id + // list needed. We deliberately ignore `ids` rather than asserting on it + // so the venue surface stays uniform across perps/futures. + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "liquidateOrders", + args: [user], + feeEventName: "OrderLiquidated", + }); + + return "skipped" in result ? { skipped: "notLiquidatable" } : { feeEarned: result.feeEarned }; + } + + async liquidatePosition(user: Address, id: Hex): Promise { + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "liquidatePosition", + args: [user, id], + feeEventName: "PositionLiquidated", + mapSkip: (errorName) => { + if (errorName === "OrdersStillOpen") return "ordersStillOpen"; + return "notLiquidatable"; + }, + }); + + return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; + } + + /** + * Read `deliveryDurationDays` lazily and cache it. The contract returns + * `uint8` (decoded as `number`); we widen to `bigint` so downstream + * arithmetic stays in bigint land. + */ + private async getDeliveryDurationDays(): Promise { + if (this.deliveryDurationDays !== undefined) return this.deliveryDurationDays; + const days = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "deliveryDurationDays", + })) as number; + this.deliveryDurationDays = BigInt(days); + return this.deliveryDurationDays; + } +} + +/** `bytes32(uint256(deliveryAt))` — same encoding the indexer uses. */ +export function deliveryAtMarketId(deliveryAt: bigint): MarketId { + return pad(toHex(deliveryAt), { size: 32 }); +} + +/** Inverse of `deliveryAtMarketId` — used by the planner / labels. */ +export function marketIdToDeliveryAt(marketId: MarketId): bigint { + return BigInt(marketId); +} diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts new file mode 100644 index 0000000..76f24b4 --- /dev/null +++ b/keeper/src/venues/perps.ts @@ -0,0 +1,173 @@ +import { keccak256, pad, toHex, type Address, type Hex } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import { HashPowerPerpsDEXAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; +import { sendLiquidate } from "../tx/liquidate.ts"; +import type { + LiquidateOrdersOutcome, + LiquidatePositionOutcome, + MarketId, + Venue, + VenueOrder, + VenuePosition, +} from "./types.ts"; + +/** + * `Venue` adapter for HashPowerPerpsDEX. Stateless beyond the wiring it + * receives — no per-instance caches; perps has a single market and the + * planner re-reads everything per liquidation cycle. + */ +export class PerpsVenue implements Venue { + readonly name = "perps" as const; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + + constructor(chain: Chain, config: Config, logger: pino.Logger) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ venue: "perps" }); + } + + marketLabel(_marketId: MarketId): string { + return "perps"; + } + + async readOpenOrders(user: Address): Promise { + const ids = (await this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserOrders", + args: [user], + })) as readonly Hex[]; + + // Each id maps 1:1 to PERPS_MARKET_ID — no per-order metadata needed + // by the planner today; the id alone is sufficient for `liquidateOrder`. + return ids.map((id) => ({ id, marketId: PERPS_MARKET_ID })); + } + + async readPositions(user: Address): Promise { + // Single-market netted position. We need entryPrice + qty + market price + // to derive `unrealizedLoss` and `notional`. + const [position, marketPrice] = await this.chain.publicClient.multicall({ + contracts: [ + { + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserPosition" as const, + args: [user] as const, + }, + { + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getMarketPrice" as const, + }, + ] as const, + allowFailure: false, + }); + + if (position.netQuantity === 0n) return []; + + 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; + const unrealizedLoss = pnl < 0n ? -pnl : 0n; + const notional = (marketPrice * absQty) / QUANTITY_SCALE; + + this.logger.debug( + { user, isLong, qty: position.netQuantity, marketPrice, unrealizedLoss, notional }, + "perps position read", + ); + + return [ + { + id: perpsPositionId(user), + marketId: PERPS_MARKET_ID, + unrealizedLoss, + notional, + }, + ]; + } + + async liquidateOrders(user: Address, ids?: readonly Hex[]): Promise { + // Perps is the only venue that takes a calldata id list — futures sweeps + // FIFO. If the planner doesn't provide ids we fetch them ourselves so the + // contract has something to chew on (the Multicall3 batching path also + // benefits from a static id list). + let targetIds = ids; + if (targetIds === undefined) { + const fetched = (await this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUserOrders", + args: [user], + })) as readonly Hex[]; + targetIds = fetched; + } + + if (targetIds.length === 0) { + // Nothing to cancel — surface as `notLiquidatable` so the planner can + // bail on this leg without rolling back the wider plan. + return { skipped: "notLiquidatable" }; + } + + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "liquidateOrders", + args: [user, targetIds], + feeEventName: "OrderLiquidated", + }); + + return "skipped" in result ? { skipped: "notLiquidatable" } : { feeEarned: result.feeEarned }; + } + + async liquidatePosition(user: Address, _id: Hex): Promise { + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "liquidatePosition", + args: [user], + feeEventName: "PositionLiquidated", + mapSkip: (errorName) => { + if (errorName === "OrdersStillOpen") return "ordersStillOpen"; + // Both `NotLiquidatable` and any other recoverable revert collapse to + // `notLiquidatable` — the planner's recheck-then-retry loop handles + // it the same way. + return "notLiquidatable"; + }, + }); + + return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; + } +} + +/** Single sentinel marketId — perps is single-market today. */ +export const PERPS_MARKET_ID: MarketId = keccak256(toHex("perps")); + +/** Perps quantities are scaled by 10^QUANTITY_DECIMALS (=6 in HashPowerPerpsDEX). */ +const QUANTITY_SCALE = 1_000_000n; + +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** + * `bytes32(uint160(user))` — perps has at most one position per user (net), + * so we synthesize a deterministic id from the user address. The contract + * itself doesn't take a positionId for `liquidatePosition`; this id is only + * used by the planner for cross-venue ranking and logging. + */ +function perpsPositionId(user: Address): Hex { + return pad(user, { size: 32 }); +} diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts new file mode 100644 index 0000000..7c7497e --- /dev/null +++ b/keeper/src/venues/types.ts @@ -0,0 +1,84 @@ +import type { Address, Hex } from "viem"; + +/** + * Opaque per-venue market identifier. + * + * Encoded forms (callers MUST treat this as opaque — only the venue itself + * decodes it): + * - perps: sentinel `keccak256("perps")` (single market) + * - futures: bytes32(uint256(deliveryAt)) + * - options: keccak256(abi.encode(strike, expiry)) + * + * Kept opaque so the coordinator can rank cross-market positions without + * caring which specific kind of market they live in. The `Venue.marketLabel` + * method renders human-friendly strings for alerts and logs. + */ +export type MarketId = Hex; + +export interface VenueOrder { + id: Hex; + marketId: MarketId; +} + +export interface VenuePosition { + id: Hex; + marketId: MarketId; + /** Loss in collateral-token decimals; 0 if break-even or in profit. */ + unrealizedLoss: bigint; + /** Notional value of the position (price × |qty|), collateral-token decimals. */ + notional: bigint; +} + +export type LiquidateOrdersOutcome = + | { feeEarned: bigint } + | { skipped: "notLiquidatable" }; + +export type LiquidatePositionOutcome = + | { feeEarned: bigint } + | { skipped: "unprofitable" | "notLiquidatable" | "ordersStillOpen" }; + +/** + * Cross-product abstraction the coordinator and planner consume. Each venue + * (Perps, Futures, Options) implements this same surface so the rest of the + * keeper is venue-agnostic. + * + * Multi-market awareness is intentional even though Perps is single-market + * today — Futures has many delivery dates and Options is M×N (strike × + * expiry). Returning `marketId`-tagged orders/positions lets the coordinator + * rank "most underwater" across markets within a venue without leaking + * venue-specific concepts. + */ +export interface Venue { + readonly name: "perps" | "futures" | "options"; + + /** + * Human-readable label for a `marketId`. Used in alert payloads and logs. + * Examples: `"perps"`, `"futures 2025-08-29"`, `"options BTC-29000C-26AUG"`. + */ + marketLabel(marketId: MarketId): string; + + /** All resting orders the user owns at this venue (across markets). */ + readOpenOrders(user: Address): Promise; + + /** All active positions the user holds at this venue (across markets). */ + readPositions(user: Address): Promise; + + /** + * Calls `liquidateOrders` on the venue. Cancels across all markets owned by + * `user` (or the supplied `ids` for venues that take a calldata id list). + * + * - Perps: takes `ids[]` so the keeper can multicall specific high-margin orders. + * - Futures: ignores `ids` — the contract sweeps FIFO until healthy. + * - Options: TBD when added. + */ + liquidateOrders(user: Address, ids?: readonly Hex[]): Promise; + + /** + * Calls `liquidatePosition(user, id)` on the venue. `id` is unique within the + * venue across all markets. Reverts on-chain with `OrdersStillOpen` if any + * orders remain — the venue surface translates that into + * `{ skipped: "ordersStillOpen" }` so the coordinator can re-run + * `liquidateOrders` without crashing the plan. + */ + liquidatePosition(user: Address, id: Hex): Promise; +} diff --git a/keeper/tests/alert/notifier.test.ts b/keeper/tests/alert/notifier.test.ts new file mode 100644 index 0000000..509f089 --- /dev/null +++ b/keeper/tests/alert/notifier.test.ts @@ -0,0 +1,194 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { keccak256, toHex, type Address } from "viem"; +import type pino from "pino"; +import { Notifier, type Alert, type WebhookPoster } from "../../src/alert/notifier.ts"; +import type { Config } from "../../src/config.ts"; + +const USER_A = "0x000000000000000000000000000000000000000a" as Address; +const USER_B = "0x000000000000000000000000000000000000000b" as Address; + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +function makeConfig(opts: { webhookUrl?: string; dedupeMs?: number } = {}): Config { + return { + alerts: { + webhookUrl: opts.webhookUrl, + dedupeMs: opts.dedupeMs ?? 60_000, + imWarnUtilization: 0.85, + imCriticalUtilization: 0.95, + }, + } as Config; +} + +function makeAlert(opts: { + user: Address; + severity: Alert["severity"]; + mmSurplus: bigint; + marketLabel?: string; + imUtil?: number; + reason?: string; +}): Alert { + return { + severity: opts.severity, + user: opts.user, + health: { + user: opts.user, + balance: 1000n, + imRequired: 100n, + mmRequired: 1000n - opts.mmSurplus, + mmSurplus: opts.mmSurplus, + imUtilization: opts.imUtil ?? 0.9, + }, + market: opts.marketLabel + ? { + venue: "futures", + marketId: keccak256(toHex(opts.marketLabel)), + marketLabel: opts.marketLabel, + } + : undefined, + reason: opts.reason ?? "im threshold breached", + }; +} + +/** + * In-memory poster that records every payload. Lets tests assert on the + * exact JSON sent without standing up an HTTP server. + */ +function makeRecordingPoster(): { poster: WebhookPoster; sent: Array<{ url: string; payload: unknown }> } { + const sent: Array<{ url: string; payload: unknown }> = []; + const poster: WebhookPoster = async (url, payload) => { + sent.push({ url, payload }); + }; + return { poster, sent }; +} + +describe("Notifier: dedupe", () => { + it("suppresses a same-severity alert within dedupeMs", async () => { + const t = { now: 1_000_000 }; + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x", dedupeMs: 60_000 }), silentLogger, { + poster, + now: () => t.now, + }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n })); + await n.drain(); + assert.equal(sent.length, 1, "first alert sends"); + + t.now += 30_000; // still inside dedupe window + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -20n })); + await n.drain(); + assert.equal(sent.length, 1, "duplicate within window dropped"); + + t.now += 60_000; // window expired + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -30n })); + await n.drain(); + assert.equal(sent.length, 2, "send again after window"); + }); + + it("treats different markets for the same user as independent dedupe keys", async () => { + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x" }), silentLogger, { poster }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n, marketLabel: "futures 2025-08" })); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n, marketLabel: "futures 2025-09" })); + await n.drain(); + assert.equal(sent.length, 2, "per-market alerts don't dedupe each other"); + }); + + it("severity promotion (warn → critical) bypasses the dedupe window", async () => { + const t = { now: 1_000_000 }; + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x", dedupeMs: 60_000 }), silentLogger, { + poster, + now: () => t.now, + }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n })); + await n.drain(); + t.now += 1_000; // well within dedupe window for warn + n.enqueue(makeAlert({ user: USER_A, severity: "critical", mmSurplus: -100n })); + await n.drain(); + assert.equal(sent.length, 2, "promotion to critical fires immediately"); + }); + + it("dedupes critical-after-critical within the dedupe window (no spurious paging)", async () => { + const t = { now: 1_000_000 }; + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x", dedupeMs: 60_000 }), silentLogger, { + poster, + now: () => t.now, + }); + // First critical sends. + n.enqueue(makeAlert({ user: USER_A, severity: "critical", mmSurplus: -100n })); + await n.drain(); + assert.equal(sent.length, 1); + // Second critical inside the window: dedupe (no warn-→-critical promotion path). + t.now += 1_000; + n.enqueue(makeAlert({ user: USER_A, severity: "critical", mmSurplus: -200n })); + await n.drain(); + assert.equal(sent.length, 1, "critical re-fire inside window is suppressed"); + }); +}); + +describe("Notifier: drain ordering", () => { + it("drains in insertion order — caller controls priority via enqueue order", async () => { + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x" }), silentLogger, { poster }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -1n })); + n.enqueue(makeAlert({ user: USER_B, severity: "critical", mmSurplus: -10n })); + n.enqueue(makeAlert({ user: USER_A, severity: "critical", mmSurplus: -100n, marketLabel: "futures 2025-08" })); + await n.drain(); + assert.equal(sent.length, 3); + const order = sent.map((s) => (s.payload as { user: Address; severity: string })); + assert.equal(order[0]?.user, USER_A); + assert.equal(order[0]?.severity, "warn"); + assert.equal(order[1]?.user, USER_B); + assert.equal(order[1]?.severity, "critical"); + assert.equal(order[2]?.user, USER_A); + assert.equal(order[2]?.severity, "critical"); + }); +}); + +describe("Notifier: webhook handling", () => { + it("drops the buffer when no webhookUrl is configured (and warns)", async () => { + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: undefined }), silentLogger, { poster }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n })); + assert.equal(n.pendingCount(), 1); + await n.drain(); + assert.equal(sent.length, 0, "no posts attempted"); + assert.equal(n.pendingCount(), 0, "buffer cleared so memory doesn't grow"); + }); + + it("re-buffers the failed alert at the head of the queue on POST failure", async () => { + let attempts = 0; + const poster: WebhookPoster = async () => { + attempts++; + if (attempts === 1) throw new Error("network down"); + }; + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x" }), silentLogger, { poster }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -10n })); + await n.drain(); + assert.equal(n.pendingCount(), 1, "failed alert re-queued"); + await n.drain(); + assert.equal(n.pendingCount(), 0, "second drain succeeds"); + assert.equal(attempts, 2); + }); + + it("serialises bigints in the payload as decimal strings (JSON-safe)", async () => { + const { poster, sent } = makeRecordingPoster(); + const n = new Notifier(makeConfig({ webhookUrl: "https://hooks/x" }), silentLogger, { poster }); + n.enqueue(makeAlert({ user: USER_A, severity: "warn", mmSurplus: -123n })); + await n.drain(); + const payload = sent[0]?.payload as { health: Record }; + assert.equal(typeof payload.health.balance, "string", "bigint rendered as string"); + assert.equal(payload.health.mmSurplus, "-123"); + // Round-trips through JSON.stringify without throwing TypeError. + JSON.stringify(payload); + }); +}); diff --git a/keeper/tests/coordinator/planner.test.ts b/keeper/tests/coordinator/planner.test.ts new file mode 100644 index 0000000..70d82d1 --- /dev/null +++ b/keeper/tests/coordinator/planner.test.ts @@ -0,0 +1,321 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { keccak256, toHex, type Address, type Hex } from "viem"; +import type pino from "pino"; +import { Planner } from "../../src/coordinator/planner.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; +import type { + LiquidateOrdersOutcome, + LiquidatePositionOutcome, + Venue, + VenueOrder, + VenuePosition, +} from "../../src/venues/types.ts"; + +const USER = "0x000000000000000000000000000000000000beef" as Address; +const VAULT = "0x000000000000000000000000000000000000000a" as Address; +const PME = "0x000000000000000000000000000000000000000b" as Address; +const MARKET_PERPS = keccak256(toHex("perps")); +const MARKET_FUT_A = keccak256(toHex("fut-a")); + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +/** A deliberately controllable Venue stub. Each method is scripted by the test. */ +interface FakeVenue extends Venue { + // Counters for assertions: + ordersCalls: number; + positionCalls: Array<{ id: Hex }>; +} + +function makeFakeVenue(name: Venue["name"], opts: { + marketId: Hex; + ordersByCall?: VenueOrder[][]; + positionsByCall?: VenuePosition[][]; + ordersOutcomeByCall?: LiquidateOrdersOutcome[]; + positionOutcomeByCall?: LiquidatePositionOutcome[]; +}): FakeVenue { + let openOrdersCall = 0; + let positionsCall = 0; + let liqOrdersCall = 0; + let liqPositionCall = 0; + const venue: FakeVenue = { + name, + ordersCalls: 0, + positionCalls: [], + marketLabel: () => `${name}-market`, + async readOpenOrders(_user) { + const list = opts.ordersByCall?.[openOrdersCall++] ?? []; + return list; + }, + async readPositions(_user) { + const list = opts.positionsByCall?.[positionsCall++] ?? []; + return list; + }, + async liquidateOrders(_user, _ids) { + venue.ordersCalls++; + const out = opts.ordersOutcomeByCall?.[liqOrdersCall++]; + return out ?? { feeEarned: 0n }; + }, + async liquidatePosition(_user, id) { + venue.positionCalls.push({ id }); + const out = opts.positionOutcomeByCall?.[liqPositionCall++]; + return out ?? { feeEarned: 0n }; + }, + }; + void opts.marketId; // marketId is informational — used by readOpenOrders/readPositions inputs + return venue; +} + +function makeChainStub(healthSequence: Array<{ balance: bigint; im: bigint; mm: bigint }>): Chain { + let invocation = 0; + return { + publicClient: { + multicall: async ({ contracts }: { contracts: readonly unknown[] }) => { + const snap = healthSequence[invocation++]; + if (snap === undefined) { + throw new Error( + `health snapshot exhausted at call #${invocation} (test scripted ${healthSequence.length})`, + ); + } + // Triple per user — match readAccountHealthBatch's contract. + assert.equal(contracts.length, 3, "single-user triple"); + return [snap.balance, snap.im, snap.mm]; + }, + }, + } as unknown as Chain; +} + +function makeConfigStub(): Config { + return { + vault: { address: VAULT }, + pme: { address: PME }, + keeper: { dryRun: false }, + } as Config; +} + +describe("Planner.run: healthy account on entry", () => { + it("short-circuits with `healthy` when mmSurplus >= 0", async () => { + const chain = makeChainStub([{ balance: 1000n, im: 200n, mm: 500n }]); + const venue = makeFakeVenue("perps", { marketId: MARKET_PERPS }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + assert.equal(outcome.kind, "healthy"); + assert.equal(venue.ordersCalls, 0, "no liquidate calls when healthy"); + assert.equal(venue.positionCalls.length, 0); + }); +}); + +describe("Planner.run: orders-leg only", () => { + it("returns `liquidated` when clearing orders restores health", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, // underwater + { balance: 1000n, im: 600n, mm: 800n }, // healthy after orders cleared + ]); + const orderId: Hex = "0x" + "aa".repeat(32) as Hex; + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[{ id: orderId, marketId: MARKET_PERPS }]], + ordersOutcomeByCall: [{ feeEarned: 5n }], + }); + + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + + assert.equal(outcome.kind, "liquidated"); + if (outcome.kind === "liquidated") { + assert.equal(outcome.feeEarned, 5n); + assert.equal(outcome.ordersClosed, 1); + assert.equal(outcome.positionsClosed, 0); + } + assert.equal(venue.ordersCalls, 1); + assert.equal(venue.positionCalls.length, 0); + }); + + it("skips a venue's liquidateOrders call when readOpenOrders returns empty", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 600n, mm: 800n }, + ]); + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], // no orders + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + await planner.run(USER); + assert.equal(venue.ordersCalls, 0, "saved the simulate round-trip"); + }); + + it("fans out liquidateOrders across every venue with open orders", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 600n, mm: 800n }, + ]); + const perps = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[{ id: "0x" + "11".repeat(32) as Hex, marketId: MARKET_PERPS }]], + ordersOutcomeByCall: [{ feeEarned: 3n }], + }); + const futures = makeFakeVenue("futures", { + marketId: MARKET_FUT_A, + ordersByCall: [[{ id: "0x" + "22".repeat(32) as Hex, marketId: MARKET_FUT_A }]], + ordersOutcomeByCall: [{ feeEarned: 4n }], + }); + const planner = new Planner(chain, makeConfigStub(), [perps, futures], silentLogger); + const outcome = await planner.run(USER); + assert.equal(perps.ordersCalls, 1); + assert.equal(futures.ordersCalls, 1); + if (outcome.kind === "liquidated") { + assert.equal(outcome.feeEarned, 7n, "fees summed across both venues"); + assert.equal(outcome.ordersClosed, 2); + } else { + assert.fail(`expected liquidated, got ${outcome.kind}`); + } + }); +}); + +describe("Planner.run: position-leg ranking and execution", () => { + it("targets the most-underwater position across venues (max unrealizedLoss)", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1200n }, // entry: under + { balance: 1000n, im: 800n, mm: 1100n }, // after orders-leg: still under + { balance: 1000n, im: 600n, mm: 800n }, // after position-leg: healthy + ]); + const lightPosId: Hex = "0x" + "01".repeat(32) as Hex; + const heavyPosId: Hex = "0x" + "02".repeat(32) as Hex; + const perps = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], + // After orders-leg the planner reads positions on every venue. Light loss. + positionsByCall: [[{ id: lightPosId, marketId: MARKET_PERPS, unrealizedLoss: 50n, notional: 1000n }]], + }); + const futures = makeFakeVenue("futures", { + marketId: MARKET_FUT_A, + ordersByCall: [[]], + // Heavy loss → must be picked first. + positionsByCall: [[{ id: heavyPosId, marketId: MARKET_FUT_A, unrealizedLoss: 500n, notional: 2000n }]], + positionOutcomeByCall: [{ feeEarned: 12n }], + }); + + const planner = new Planner(chain, makeConfigStub(), [perps, futures], silentLogger); + const outcome = await planner.run(USER); + + assert.equal(perps.positionCalls.length, 0, "perps light position never touched"); + assert.deepEqual(futures.positionCalls.map((c) => c.id), [heavyPosId]); + if (outcome.kind === "liquidated") { + assert.equal(outcome.positionsClosed, 1); + assert.equal(outcome.feeEarned, 12n); + } else { + assert.fail(`expected liquidated, got ${outcome.kind}`); + } + }); + + it("tiebreaks equal unrealizedLoss by larger notional", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 950n, mm: 1100n }, // still under after orders-leg + { balance: 1000n, im: 600n, mm: 800n }, + ]); + const smallId: Hex = "0x" + "0a".repeat(32) as Hex; + const bigId: Hex = "0x" + "0b".repeat(32) as Hex; + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], + positionsByCall: [[ + { id: smallId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 500n }, + { id: bigId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 5000n }, + ]], + positionOutcomeByCall: [{ feeEarned: 2n }], + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + await planner.run(USER); + assert.deepEqual(venue.positionCalls.map((c) => c.id), [bigId]); + }); + + it("on OrdersStillOpen, replays orders-leg and retries on the next iteration", async () => { + // Sequence of health snapshots: + // 1. entry — under + // 2. after 1st orders-leg — still under + // 3. after stale position attempt — still under (no-op since revert) + // 4. after replayed orders-leg + 2nd position-leg attempt — healthy + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 600n, mm: 800n }, + ]); + const positionId: Hex = "0x" + "33".repeat(32) as Hex; + const replayedOrderId: Hex = "0x" + "44".repeat(32) as Hex; + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + // orders-leg #1 (initial), rank #1, replayed orders-leg, rank #2 + ordersByCall: [ + [], // initial: no open orders + [{ id: replayedOrderId, marketId: MARKET_PERPS }], // race-injected + ], + ordersOutcomeByCall: [{ feeEarned: 1n }], // for the replayed call + positionsByCall: [ + [{ id: positionId, marketId: MARKET_PERPS, unrealizedLoss: 200n, notional: 1000n }], + [{ id: positionId, marketId: MARKET_PERPS, unrealizedLoss: 200n, notional: 1000n }], + ], + positionOutcomeByCall: [ + { skipped: "ordersStillOpen" }, + { feeEarned: 7n }, + ], + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + + assert.equal(venue.positionCalls.length, 2, "retried position-leg after orders replay"); + assert.equal(venue.ordersCalls, 1, "only the replayed orders-leg called liquidateOrders (initial was empty)"); + if (outcome.kind === "liquidated") { + assert.equal(outcome.positionsClosed, 1); + assert.equal(outcome.ordersClosed, 1); + assert.equal(outcome.feeEarned, 8n, "1 (orders) + 7 (position) = 8"); + } else { + assert.fail(`expected liquidated, got ${outcome.kind}`); + } + }); + + it("returns `stalled: unprofitable` when the worst position is unprofitable", async () => { + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 950n, mm: 1100n }, + ]); + const id: Hex = "0x" + "55".repeat(32) as Hex; + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], + positionsByCall: [[{ id, marketId: MARKET_PERPS, unrealizedLoss: 50n, notional: 100n }]], + positionOutcomeByCall: [{ skipped: "unprofitable" }], + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + assert.equal(outcome.kind, "stalled"); + if (outcome.kind === "stalled") { + assert.equal(outcome.reason, "unprofitable"); + } + }); + + it("returns `badDebt` when no positions remain but mmSurplus stays negative", async () => { + // After orders-leg there's nothing left to close — pure bad debt. + const chain = makeChainStub([ + { balance: 100n, im: 200n, mm: 500n }, // entry under + { balance: 100n, im: 200n, mm: 500n }, // still under after empty orders-leg + ]); + const venue = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], // nothing to clear + positionsByCall: [[]], // and no positions either + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + assert.equal(outcome.kind, "badDebt"); + }); +}); diff --git a/keeper/tests/coordinator/queue.test.ts b/keeper/tests/coordinator/queue.test.ts new file mode 100644 index 0000000..41b9f28 --- /dev/null +++ b/keeper/tests/coordinator/queue.test.ts @@ -0,0 +1,123 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { CoordinatorQueue, compare } from "../../src/coordinator/queue.ts"; +import type { AccountHealth } from "../../src/pme/health.ts"; + +function userAt(idx: number): Address { + return `0x${(idx + 1).toString(16).padStart(40, "0")}` as Address; +} + +function health(opts: { user?: Address; mmSurplus: bigint; imUtil?: number }): AccountHealth { + return { + user: opts.user ?? userAt(0), + balance: 1000n, + imRequired: 100n, + mmRequired: 1000n - opts.mmSurplus, + mmSurplus: opts.mmSurplus, + imUtilization: opts.imUtil ?? 0.5, + }; +} + +describe("coordinator queue: ordering policy (compare)", () => { + it("ranks lower mmSurplus first (most-underwater wins)", () => { + assert.ok(compare(health({ mmSurplus: -100n }), health({ mmSurplus: -10n })) < 0); + assert.ok(compare(health({ mmSurplus: -10n }), health({ mmSurplus: -100n })) > 0); + }); + + it("returns 0 for equal mmSurplus regardless of imUtilization", () => { + // imUtilization is intentionally NOT a tiebreak — bigint mmSurplus ties are + // vanishingly rare and any tiebreak among already-underwater accounts is moot. + const a = health({ mmSurplus: -50n, imUtil: 0.9 }); + const b = health({ mmSurplus: -50n, imUtil: 0.7 }); + assert.equal(compare(a, b), 0); + }); + + it("handles bigint values larger than Number.MAX_SAFE_INTEGER without truncation", () => { + const a = health({ mmSurplus: -(2n ** 70n) }); + const b = health({ mmSurplus: -1n }); + assert.ok(compare(a, b) < 0, "very-negative mmSurplus still ranks ahead"); + }); +}); + +describe("coordinator queue: gating on mmSurplus", () => { + it("rejects healthy snapshots (mmSurplus >= 0) and reports false from upsert", () => { + const q = new CoordinatorQueue(); + assert.equal(q.upsert(health({ user: userAt(0), mmSurplus: 50n })), false); + assert.equal(q.upsert(health({ user: userAt(1), mmSurplus: 0n })), false, "mmSurplus=0 is the boundary; not yet liquidatable"); + assert.equal(q.size(), 0); + }); + + it("a healthy snapshot for an already-enqueued user removes them from the queue", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -50n })); + assert.equal(q.size(), 1); + // Account recovered (deposit, price move, etc.) → drop from queue. + assert.equal(q.upsert(health({ user: userAt(0), mmSurplus: 100n })), false); + assert.equal(q.size(), 0); + }); + + it("reports true from upsert when the user ends up in the queue", () => { + const q = new CoordinatorQueue(); + assert.equal(q.upsert(health({ user: userAt(0), mmSurplus: -10n })), true); + }); +}); + +describe("coordinator queue: upsert / pop / remove", () => { + it("pops accounts in most-underwater-first order", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + q.upsert(health({ user: userAt(1), mmSurplus: -100n })); + q.upsert(health({ user: userAt(2), mmSurplus: -50n })); + + assert.equal(q.pop()?.user, userAt(1), "most-negative first"); + assert.equal(q.pop()?.user, userAt(2)); + assert.equal(q.pop()?.user, userAt(0)); + assert.equal(q.pop(), undefined); + }); + + it("upsert is idempotent on user — replacing in place keeps the set unique", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + q.upsert(health({ user: userAt(0), mmSurplus: -100n })); + assert.equal(q.size(), 1, "no duplicate entry for the same user"); + assert.equal(q.peek()?.mmSurplus, -100n, "latest snapshot wins"); + }); + + it("upsert re-orders existing entries when mmSurplus changes", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + q.upsert(health({ user: userAt(1), mmSurplus: -50n })); + // user(0) gets worse than user(1) → must move to head. + q.upsert(health({ user: userAt(0), mmSurplus: -200n })); + assert.equal(q.pop()?.user, userAt(0)); + assert.equal(q.pop()?.user, userAt(1)); + }); + + it("remove deletes by user without affecting the rest of the order", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + q.upsert(health({ user: userAt(1), mmSurplus: -100n })); + q.upsert(health({ user: userAt(2), mmSurplus: -50n })); + q.remove(userAt(1)); + assert.equal(q.size(), 2); + assert.equal(q.pop()?.user, userAt(2)); + assert.equal(q.pop()?.user, userAt(0)); + }); + + it("remove on an unknown user is a no-op", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + q.remove(userAt(99)); + assert.equal(q.size(), 1); + }); + + it("snapshot returns a copy that doesn't mutate the underlying queue", () => { + const q = new CoordinatorQueue(); + q.upsert(health({ user: userAt(0), mmSurplus: -10n })); + const snap = q.snapshot(); + assert.equal(snap.length, 1); + (snap as AccountHealth[]).pop(); + assert.equal(q.size(), 1); + }); +}); diff --git a/keeper/tests/discovery/tracker.test.ts b/keeper/tests/discovery/tracker.test.ts new file mode 100644 index 0000000..d7a667f --- /dev/null +++ b/keeper/tests/discovery/tracker.test.ts @@ -0,0 +1,352 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress, type Address } from "viem"; +import type pino from "pino"; +import { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +function userAt(idx: number): Address { + return getAddress(`0x${(idx + 1).toString(16).padStart(40, "0")}` as Address); +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +/** + * Backfill is event-source driven: each `getContractEvents` call carries an + * `address` and `eventName`. The script keys log lists by `"address:eventName"` + * (lowercased address) so perps and futures `OrderCreated` don't collide. + * Anything not in the map returns []. `readContract` is still stubbed for + * tests that need it. + */ +function scriptKey(address: Address, eventName: string): string { + return `${address.toLowerCase()}:${eventName}`; +} + +function makeChain( + opts: { + perpsUsers?: readonly Address[]; + readContractFails?: boolean; + eventScript?: Record; + getContractEventsFails?: boolean; + blockNumber?: bigint; + } = {}, +): Chain { + return { + publicClient: { + readContract: async () => { + if (opts.readContractFails) throw new Error("rpc down"); + return opts.perpsUsers ?? []; + }, + getBlockNumber: async () => opts.blockNumber ?? 1000n, + getContractEvents: async ({ + address, + eventName, + }: { + address: Address; + eventName: string; + }) => { + if (opts.getContractEventsFails) throw new Error("rpc down"); + return opts.eventScript?.[scriptKey(address, eventName)] ?? []; + }, + // start() iterates watchContractEvent — return a no-op unwatcher. + watchContractEvent: () => () => undefined, + }, + } as unknown as Chain; +} + +function makeConfig(opts: { discoveryMode?: Config["chain"]["discoveryMode"] } = {}): Config { + return { + chain: { discoveryMode: opts.discoveryMode ?? "events" }, + vault: { address: userAt(100) }, + perps: { address: userAt(101) }, + futures: { address: userAt(102) }, + } as Config; +} + +describe("ParticipantTracker: add / remove / list", () => { + it("dedupes additions and reports `added` only on first insert", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + assert.equal(t.add(userAt(0)), true, "first add"); + assert.equal(t.add(userAt(0)), false, "duplicate"); + assert.equal(t.size(), 1); + }); + + it("treats addresses as case-insensitive (checksum-normalised)", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + const lower = userAt(0).toLowerCase() as Address; + const upper = getAddress(userAt(0)); + assert.equal(t.add(lower), true); + assert.equal(t.add(upper), false, "same address, different case = same entry"); + assert.equal(t.size(), 1); + assert.ok(t.has(lower)); + assert.ok(t.has(upper)); + }); + + it("addBatch returns the count of new additions, not total", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + t.add(userAt(0)); + const added = t.addBatch([userAt(0), userAt(1), userAt(2)]); + assert.equal(added, 2, "userAt(0) was already present"); + assert.equal(t.size(), 3); + }); + + it("remove returns true only if the user was tracked", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + t.add(userAt(0)); + assert.equal(t.remove(userAt(0)), true); + assert.equal(t.remove(userAt(0)), false); + }); + + it("list returns a snapshot — mutating it doesn't affect the tracker", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + t.addBatch([userAt(0), userAt(1)]); + const snap = t.list(); + snap.pop(); + assert.equal(t.size(), 2); + }); +}); + +describe("ParticipantTracker: onAdded listeners", () => { + it("invokes every registered listener with the new address", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + const seen: Address[] = []; + t.onAdded((u) => seen.push(u)); + t.add(userAt(0)); + t.add(userAt(1)); + assert.deepEqual(seen, [getAddress(userAt(0)), getAddress(userAt(1))]); + }); + + it("does not invoke listeners on duplicate adds", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + let calls = 0; + t.onAdded(() => calls++); + t.add(userAt(0)); + t.add(userAt(0)); + assert.equal(calls, 1); + }); + + it("unsubscribe stops further notifications", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + let calls = 0; + const off = t.onAdded(() => calls++); + t.add(userAt(0)); + off(); + t.add(userAt(1)); + assert.equal(calls, 1); + }); + + it("a throwing listener doesn't block the others", () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + let bCalls = 0; + t.onAdded(() => { + throw new Error("boom"); + }); + t.onAdded(() => bCalls++); + t.add(userAt(0)); + assert.equal(bCalls, 1, "second listener still fired despite first throwing"); + }); +}); + +describe("ParticipantTracker: backfill", () => { + it("ingests participants from every event source across all chunks", async () => { + // Each mocked log shape mirrors what viem's getContractEvents would + // hand to our handlers — only `args` is read. Note the perps/futures + // OrderCreated logs go to separate handlers keyed by contract address, + // so the script is keyed (address, eventName). + const config = makeConfig(); + const t = new ParticipantTracker( + makeChain({ + blockNumber: 1000n, + eventScript: { + [scriptKey(config.vault.address, "Deposited")]: [ + { args: { user: userAt(0) } }, + ], + [scriptKey(config.vault.address, "Transfer")]: [ + { args: { from: userAt(1), to: userAt(2) } }, + ], + [scriptKey(config.perps.address, "OrderCreated")]: [ + { args: { participant: userAt(3) } }, + ], + [scriptKey(config.perps.address, "OrderMatched")]: [ + { args: { maker: userAt(4), taker: userAt(5) } }, + ], + [scriptKey(config.futures.address, "OrderCreated")]: [ + { args: { participant: userAt(6) } }, + ], + [scriptKey(config.futures.address, "PositionCreated")]: [ + { args: { seller: userAt(7), buyer: userAt(8) } }, + ], + }, + }), + config, + silentLogger, + ); + await t.backfill(0n, 500n); + assert.equal(t.size(), 9); + }); + + it("chunks the block range and calls getContractEvents per chunk", async () => { + const calls: Array<{ from: bigint; to: bigint; eventName: string }> = []; + const chain = { + publicClient: { + getBlockNumber: async () => 2500n, + getContractEvents: async (params: { + fromBlock: bigint; + toBlock: bigint; + eventName: string; + }) => { + calls.push({ from: params.fromBlock, to: params.toBlock, eventName: params.eventName }); + return []; + }, + watchContractEvent: () => () => undefined, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig(), silentLogger); + await t.backfill(0n, 1000n); + // 6 sources × 3 chunks ([0,999], [1000,1999], [2000,2500]) = 18 calls. + assert.equal(calls.length, 18); + // Spot-check the chunk boundary clamping on the last page. + const deposited = calls.filter((c) => c.eventName === "Deposited"); + assert.deepEqual( + deposited.map((c) => [c.from, c.to]), + [ + [0n, 999n], + [1000n, 1999n], + [2000n, 2500n], + ], + ); + }); + + it("survives an RPC failure on one source and continues with the rest", async () => { + let calls = 0; + const chain = { + publicClient: { + getBlockNumber: async () => 100n, + getContractEvents: async ({ eventName }: { eventName: string }) => { + calls++; + if (eventName === "Deposited") throw new Error("rpc down"); + if (eventName === "Transfer") return [{ args: { from: userAt(0), to: userAt(1) } }]; + return []; + }, + watchContractEvent: () => () => undefined, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig(), silentLogger); + await t.backfill(0n, 1000n); + assert.ok(calls >= 6, "all six sources attempted despite Deposited failure"); + // Transfer still ingested. + assert.equal(t.size(), 2); + }); + + it("is a no-op when discoveryMode=webhook", async () => { + let calls = 0; + const chain = { + publicClient: { + getBlockNumber: async () => { + calls++; + return 100n; + }, + getContractEvents: async () => { + calls++; + return []; + }, + watchContractEvent: () => () => undefined, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig({ discoveryMode: "webhook" }), silentLogger); + await t.backfill(0n, 1000n); + assert.equal(calls, 0, "no RPC traffic in webhook-only mode"); + }); + + it("returns early when fromBlock > head", async () => { + let eventCalls = 0; + const chain = { + publicClient: { + getBlockNumber: async () => 50n, + getContractEvents: async () => { + eventCalls++; + return []; + }, + watchContractEvent: () => () => undefined, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig(), silentLogger); + await t.backfill(100n, 10n); + assert.equal(eventCalls, 0); + }); + + it("rejects non-positive chunkSize", async () => { + const t = new ParticipantTracker(makeChain(), makeConfig(), silentLogger); + await assert.rejects(() => t.backfill(0n, 0n), /chunkSize must be positive/); + }); + + it("reads perps OrderCreated from `participant`, not `user`", async () => { + // Regression: an earlier generic handler read `args.user`, which doesn't + // exist on perps OrderCreated — the actual field is `participant`. The + // typed per-event handler must read the correct field or this user + // never gets added until OrderMatched fires. + const config = makeConfig(); + const t = new ParticipantTracker( + makeChain({ + blockNumber: 100n, + eventScript: { + [scriptKey(config.perps.address, "OrderCreated")]: [ + { args: { participant: userAt(7) } }, + { args: { user: userAt(8) } }, // wrong field — must be ignored + ], + }, + }), + config, + silentLogger, + ); + await t.backfill(0n, 1000n); + assert.equal(t.has(userAt(7)), true, "participant address tracked"); + assert.equal(t.has(userAt(8)), false, "stray `user` field ignored"); + }); +}); + +describe("ParticipantTracker: discoveryMode gating", () => { + it("start() is a no-op (no subscriptions wired) when discoveryMode=webhook", async () => { + let watchCount = 0; + const chain = { + publicClient: { + watchContractEvent: () => { + watchCount++; + return () => undefined; + }, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig({ discoveryMode: "webhook" }), silentLogger); + await t.start(); + assert.equal(watchCount, 0, "no subscriptions opened in webhook-only mode"); + }); + + it("start() wires multiple subscriptions when discoveryMode=events", async () => { + let watchCount = 0; + const chain = { + publicClient: { + watchContractEvent: () => { + watchCount++; + return () => undefined; + }, + readContract: async () => [], + }, + } as unknown as Chain; + const t = new ParticipantTracker(chain, makeConfig({ discoveryMode: "events" }), silentLogger); + await t.start(); + assert.ok(watchCount >= 4, `expected ≥4 subscriptions, got ${watchCount}`); + t.stop(); + }); +}); diff --git a/keeper/tests/discovery/webhook.test.ts b/keeper/tests/discovery/webhook.test.ts new file mode 100644 index 0000000..a38db41 --- /dev/null +++ b/keeper/tests/discovery/webhook.test.ts @@ -0,0 +1,155 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress, type Address } from "viem"; +import type pino from "pino"; +import { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import { WebhookIngester, __testing } from "../../src/discovery/webhook.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +function userAt(idx: number): Address { + return getAddress(`0x${(idx + 1).toString(16).padStart(40, "0")}` as Address); +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +function makeStubs(opts: { secret?: string; mode?: Config["chain"]["discoveryMode"] } = {}): { + ingester: WebhookIngester; + tracker: ParticipantTracker; +} { + const chain = { + publicClient: { + readContract: async () => [], + watchContractEvent: () => () => undefined, + }, + } as unknown as Chain; + const config = { + chain: { discoveryMode: opts.mode ?? "webhook" }, + vault: { address: userAt(100) }, + perps: { address: userAt(101) }, + futures: { address: userAt(102) }, + triggers: { webhookPort: 0, webhookSecret: opts.secret }, + } as Config; + const tracker = new ParticipantTracker(chain, config, silentLogger); + const ingester = new WebhookIngester(config, tracker, silentLogger); + return { ingester, tracker }; +} + +describe("WebhookIngester: payload extraction", () => { + it("pulls addresses out of the standard `{ data: [...] }` shape", () => { + const addrs = __testing.extractAddresses({ + data: [ + { user: userAt(0).toLowerCase(), other: "ignored" }, + { participant: userAt(1) }, + ], + }); + assert.equal(addrs.length, 2); + assert.ok(addrs.includes(userAt(0).toLowerCase() as Address)); + assert.ok(addrs.includes(userAt(1))); + }); + + it("supports the alternate `{ records: [...] }` shape", () => { + const addrs = __testing.extractAddresses({ + records: [{ from: userAt(0), to: userAt(1) }], + }); + assert.equal(addrs.length, 2); + }); + + it("supports a top-level array payload", () => { + const addrs = __testing.extractAddresses([ + { seller: userAt(0), buyer: userAt(1) }, + { liquidator: userAt(2) }, + ]); + assert.equal(addrs.length, 3); + }); + + it("falls back to a single-record object when neither `data` nor `records` is present", () => { + const addrs = __testing.extractAddresses({ user: userAt(0) }); + assert.equal(addrs.length, 1); + assert.equal(addrs[0], userAt(0)); + }); + + it("dedupes identical addresses across records (single Set return)", () => { + const addrs = __testing.extractAddresses({ + data: [ + { user: userAt(0) }, + { participant: userAt(0) }, + { from: userAt(0) }, + ], + }); + assert.equal(addrs.length, 1); + }); + + it("ignores non-address strings without crashing", () => { + const addrs = __testing.extractAddresses({ + data: [{ user: "not-a-hex-string", participant: userAt(0) }], + }); + assert.deepEqual(addrs, [userAt(0)]); + }); + + it("ignores fields with non-string types", () => { + const addrs = __testing.extractAddresses({ + data: [ + { user: 12345 }, + { participant: null }, + { seller: userAt(0) }, + ], + }); + assert.deepEqual(addrs, [userAt(0)]); + }); + + it("returns empty for null / undefined / primitive payloads", () => { + assert.equal(__testing.extractAddresses(null).length, 0); + assert.equal(__testing.extractAddresses(undefined).length, 0); + assert.equal(__testing.extractAddresses(42).length, 0); + assert.equal(__testing.extractAddresses("hello").length, 0); + }); +}); + +describe("WebhookIngester: ingest -> tracker", () => { + it("returns the count of newly-tracked addresses (deduped against current set)", () => { + const { ingester, tracker } = makeStubs(); + tracker.add(userAt(0)); + + const added = ingester.ingest({ + data: [{ user: userAt(0) }, { user: userAt(1) }, { participant: userAt(2) }], + }); + + assert.equal(added, 2, "userAt(0) was already tracked"); + assert.equal(tracker.size(), 3); + }); + + it("an empty / unparseable-shape payload reports added=0 without throwing", () => { + const { ingester, tracker } = makeStubs(); + assert.equal(ingester.ingest({ data: [] }), 0); + assert.equal(ingester.ingest("nonsense"), 0); + assert.equal(tracker.size(), 0); + }); +}); + +describe("WebhookIngester: HTTP server lifecycle", () => { + it("does not start an HTTP server when discoveryMode=events", async () => { + const { ingester } = makeStubs({ mode: "events" }); + await ingester.start(); + // No server bound — stop() should be a no-op (no throw). + await ingester.stop(); + }); + + it("listens on an ephemeral port and accepts a valid POST", async () => { + const { ingester, tracker } = makeStubs({ mode: "both" }); + await ingester.start(); + // Hard to grab the port from the public surface — the server bound on + // port 0 means we ask Node for the actual address. Re-create via a + // direct fetch using a typed handle. + // For unit tests we exercise `ingest()` directly (covered above) and + // verify that lifecycle calls don't throw. + await ingester.stop(); + assert.equal(tracker.size(), 0); + }); +}); diff --git a/keeper/tests/oracle/priceFeed.test.ts b/keeper/tests/oracle/priceFeed.test.ts new file mode 100644 index 0000000..7c8f9a5 --- /dev/null +++ b/keeper/tests/oracle/priceFeed.test.ts @@ -0,0 +1,159 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address } from "viem"; +import { PriceFeed, type PriceUpdate } from "../../src/oracle/priceFeed.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const HASHPRICE = "0x000000000000000000000000000000000000aa01" as Address; +const BTC_FEED = "0x000000000000000000000000000000000000aa02" as Address; + +function makeConfig(): Config { + return { + oracle: { + hashpriceUsdcAddress: HASHPRICE, + btcUsdcFeedAddress: BTC_FEED, + priceMoveTriggerBps: 0, + }, + } as Config; +} + +const silentLogger = pino({ level: "silent" }); + +interface ChainStub { + chain: Chain; + setAnswer: (answer: bigint) => void; + fireAnswerUpdated: () => Promise; + reads: number; +} + +/** + * Stub implementing the two methods PriceFeed touches: + * - readContract: resolves `decimals` and `latestRoundData` for HashpriceUSD. + * - watchContractEvent: registers a synthetic listener; tests trigger events + * via `fireAnswerUpdated`. + * + * Returns enough of a Chain shape that PriceFeed compiles and runs against it. + */ +function makeChainStub(initialAnswer: bigint, decimals: number): ChainStub { + let currentAnswer = initialAnswer; + let onLogs: (() => void) | undefined; + let reads = 0; + const chain = { + publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") return decimals; + if (functionName === "latestRoundData") { + reads++; + return [1n, currentAnswer, 1_000n, 1_000n, 1n] as const; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + watchContractEvent: ({ onLogs: cb }: { onLogs: () => void }) => { + onLogs = cb; + return () => { + onLogs = undefined; + }; + }, + }, + } as unknown as Chain; + return { + chain, + setAnswer: (answer) => { + currentAnswer = answer; + }, + fireAnswerUpdated: async () => { + if (onLogs === undefined) throw new Error("watchContractEvent was not registered"); + onLogs(); + // The watcher dispatches `void this.refresh(...)` — yield to let the + // promise chain run to completion before the test inspects state. + await new Promise((r) => setTimeout(r, 0)); + }, + get reads() { + return reads; + }, + }; +} + +describe("oracle/priceFeed: lifecycle + dispatch", () => { + it("rebases the oracle answer to token decimals on first read", async () => { + // oracle returns 8-decimal answer ($1.00 = 100_000_000); token is 6-decimal. + // → rescaled to 1_000_000. + const stub = makeChainStub(100_000_000n, 8); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await feed.start(); + assert.equal(feed.current(), 1_000_000n); + feed.stop(); + }); + + it("rejects oracles whose decimals are smaller than the token's", async () => { + const stub = makeChainStub(1n, 4); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await assert.rejects(feed.start(), /oracle decimals.*<.*token decimals/); + }); + + it("emits a PriceUpdate when the answer changes after an AnswerUpdated event", async () => { + const stub = makeChainStub(100_000_000n, 8); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await feed.start(); + const updates: PriceUpdate[] = []; + feed.onUpdate((u) => updates.push(u)); + + stub.setAnswer(110_000_000n); + await stub.fireAnswerUpdated(); + + assert.equal(updates.length, 1); + assert.equal(updates[0]?.prev, 1_000_000n); + assert.equal(updates[0]?.next, 1_100_000n); + feed.stop(); + }); + + it("does NOT emit a PriceUpdate when the answer is unchanged", async () => { + const stub = makeChainStub(100_000_000n, 8); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await feed.start(); + const updates: PriceUpdate[] = []; + feed.onUpdate((u) => updates.push(u)); + + // Same answer — no listener call. + await stub.fireAnswerUpdated(); + assert.equal(updates.length, 0); + feed.stop(); + }); + + it("ignores non-positive answers (oracle hiccup) without notifying listeners", async () => { + const stub = makeChainStub(100_000_000n, 8); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await feed.start(); + const updates: PriceUpdate[] = []; + feed.onUpdate((u) => updates.push(u)); + + stub.setAnswer(0n); + await stub.fireAnswerUpdated(); + stub.setAnswer(-1n); + await stub.fireAnswerUpdated(); + assert.equal(updates.length, 0); + // current() retains the last good value. + assert.equal(feed.current(), 1_000_000n); + feed.stop(); + }); + + it("unsubscribe stops further dispatch", async () => { + const stub = makeChainStub(100_000_000n, 8); + const feed = new PriceFeed(stub.chain, makeConfig(), silentLogger, 6); + await feed.start(); + const updates: PriceUpdate[] = []; + const unsubscribe = feed.onUpdate((u) => updates.push(u)); + + stub.setAnswer(110_000_000n); + await stub.fireAnswerUpdated(); + assert.equal(updates.length, 1); + + unsubscribe(); + stub.setAnswer(120_000_000n); + await stub.fireAnswerUpdated(); + assert.equal(updates.length, 1, "should not have received second update after unsubscribe"); + feed.stop(); + }); +}); diff --git a/keeper/tests/pme/health.test.ts b/keeper/tests/pme/health.test.ts new file mode 100644 index 0000000..3c4d1c0 --- /dev/null +++ b/keeper/tests/pme/health.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { computeUtilization, readAccountHealthBatch } from "../../src/pme/health.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const VAULT = "0x0000000000000000000000000000000000000001" as Address; +const PME = "0x0000000000000000000000000000000000000002" as Address; + +function userAt(idx: number): Address { + return `0x${(idx + 1).toString(16).padStart(40, "0")}` as Address; +} + +/** + * Minimal stub that emulates `publicClient.multicall({ contracts, allowFailure: false })`. + * The handler receives the calls in order and returns one result per call — + * matching viem's contract. + */ +function makeChainStub(handler: (calls: readonly unknown[]) => readonly unknown[]) { + let multicallInvocations = 0; + const stub = { + publicClient: { + multicall: async ({ contracts }: { contracts: readonly unknown[] }) => { + multicallInvocations++; + return handler(contracts); + }, + }, + } as unknown as Chain; + return { stub, getInvocations: () => multicallInvocations }; +} + +function makeConfigStub(): Config { + return { + vault: { address: VAULT }, + pme: { address: PME }, + // Other config fields unused by readAccountHealthBatch — minimal cast is fine. + } as Config; +} + +describe("pme/health: computeUtilization", () => { + it("returns 0 for an idle account (both 0)", () => { + assert.equal(computeUtilization(0n, 0n), 0); + }); + + it("returns +Infinity when balance is 0 but IM is required (already underwater)", () => { + assert.equal(computeUtilization(100n, 0n), Number.POSITIVE_INFINITY); + }); + + it("returns 1 at exactly the IM boundary", () => { + assert.equal(computeUtilization(1_000n, 1_000n), 1); + }); + + it("returns 0.85 for healthy 85% IM utilization", () => { + assert.equal(computeUtilization(850n, 1_000n), 0.85); + }); + + it("preserves ~6 decimal digits of precision via ppm scaling", () => { + // 1234567 / 10000000 = 0.1234567 → ppm scaling truncates to 0.123456 + const u = computeUtilization(1_234_567n, 10_000_000n); + assert.ok(Math.abs(u - 0.1234567) < 1e-6); + }); +}); + +describe("pme/health: readAccountHealthBatch", () => { + it("returns empty for an empty user list without invoking multicall", async () => { + const { stub, getInvocations } = makeChainStub(() => []); + const result = await readAccountHealthBatch(stub, makeConfigStub(), []); + assert.equal(result.length, 0); + assert.equal(getInvocations(), 0); + }); + + it("issues exactly 3 calls per user in a single multicall when chunk fits", async () => { + const users = [userAt(0), userAt(1), userAt(2)]; + const { stub, getInvocations } = makeChainStub((calls) => { + assert.equal(calls.length, users.length * 3); + // Per-user triple: balanceOf(vault), computePortfolioIM(pme), computePortfolioMM(pme) + users.forEach((user, i) => { + const a = calls[i * 3] as { address: Address; functionName: string; args: unknown[] }; + const b = calls[i * 3 + 1] as { address: Address; functionName: string; args: unknown[] }; + const c = calls[i * 3 + 2] as { address: Address; functionName: string; args: unknown[] }; + assert.equal(a.address, VAULT); + assert.equal(a.functionName, "balanceOf"); + assert.deepEqual(a.args, [user]); + assert.equal(b.address, PME); + assert.equal(b.functionName, "computePortfolioIM"); + assert.deepEqual(b.args, [user]); + assert.equal(c.address, PME); + assert.equal(c.functionName, "computePortfolioMM"); + assert.deepEqual(c.args, [user]); + }); + // Return triples: balance=1000+i, im=400+i, mm=200+i + return calls.map((_, idx) => { + const triple = idx % 3; + const u = Math.floor(idx / 3); + if (triple === 0) return BigInt(1000 + u); + if (triple === 1) return BigInt(400 + u); + return BigInt(200 + u); + }); + }); + + const result = await readAccountHealthBatch(stub, makeConfigStub(), users); + + assert.equal(getInvocations(), 1); + assert.equal(result.length, 3); + users.forEach((user, i) => { + const h = result[i]!; + assert.equal(h.user, user); + assert.equal(h.balance, BigInt(1000 + i)); + assert.equal(h.imRequired, BigInt(400 + i)); + assert.equal(h.mmRequired, BigInt(200 + i)); + assert.equal(h.mmSurplus, BigInt(1000 + i) - BigInt(200 + i)); + assert.ok(Math.abs(h.imUtilization - (400 + i) / (1000 + i)) < 1e-6); + }); + }); + + it("flags an underwater account with negative mmSurplus", async () => { + const users = [userAt(0)]; + const { stub } = makeChainStub(() => [100n, 80n, 150n]); + const [health] = await readAccountHealthBatch(stub, makeConfigStub(), users); + assert.ok(health, "result has one element"); + assert.equal(health.mmSurplus, -50n); + assert.ok(health.mmSurplus < 0n, "mmSurplus<0 means liquidatable"); + }); + + it("chunks the user list when above chunkSize and concatenates results in order", async () => { + const users = Array.from({ length: 5 }, (_, i) => userAt(i)); + const { stub, getInvocations } = makeChainStub((calls) => + calls.map((_, idx) => { + const triple = idx % 3; + // Embed the per-call user index into the bigint so we can verify ordering. + const u = Math.floor(idx / 3); + if (triple === 0) return BigInt(10_000 + u); + if (triple === 1) return BigInt(20_000 + u); + return BigInt(30_000 + u); + }), + ); + + const result = await readAccountHealthBatch(stub, makeConfigStub(), users, 2); + + // 5 users / chunk 2 = 3 multicalls + assert.equal(getInvocations(), 3); + assert.equal(result.length, 5); + // The per-chunk user index resets to 0 each chunk, so chunk-aware decoding: + // chunks: [u0,u1], [u2,u3], [u4] + const chunkLayout = [ + { offset: 0, len: 2 }, + { offset: 2, len: 2 }, + { offset: 4, len: 1 }, + ]; + for (const { offset, len } of chunkLayout) { + for (let i = 0; i < len; i++) { + const r = result[offset + i]!; + assert.equal(r.user, users[offset + i]); + assert.equal(r.balance, BigInt(10_000 + i)); + assert.equal(r.imRequired, BigInt(20_000 + i)); + assert.equal(r.mmRequired, BigInt(30_000 + i)); + } + } + }); +}); diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts new file mode 100644 index 0000000..1fea37d --- /dev/null +++ b/keeper/tests/predict/coordinator.test.ts @@ -0,0 +1,310 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address } from "viem"; +import { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; +import { CoordinatorQueue } from "../../src/coordinator/queue.ts"; +import { PriceFeed } from "../../src/oracle/priceFeed.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; +import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; +import type { ParticipantTracker, TrackerListener } from "../../src/discovery/tracker.ts"; + +const HASHPRICE = "0x000000000000000000000000000000000000aa01" as Address; +const BTC_FEED = "0x000000000000000000000000000000000000aa02" as Address; +const VAULT = "0x000000000000000000000000000000000000aa03" as Address; +const PME = "0x000000000000000000000000000000000000aa04" as Address; +const PERPS = "0x000000000000000000000000000000000000aa05" as Address; +const FUTURES = "0x000000000000000000000000000000000000aa06" as Address; + +const USER = "0x1111111111111111111111111111111111111111" as Address; + +const silentLogger = pino({ level: "silent" }); + +function makeConfig(priceMoveTriggerBps = 0): Config { + return { + oracle: { + hashpriceUsdcAddress: HASHPRICE, + btcUsdcFeedAddress: BTC_FEED, + priceMoveTriggerBps, + }, + vault: { address: VAULT }, + pme: { address: PME }, + perps: { address: PERPS }, + futures: { address: FUTURES }, + } as Config; +} + +interface Wired { + chain: Chain; + config: Config; + tracker: { + instance: ParticipantTracker; + fireAdded: (user: Address) => void; + fireChanged: (user: Address) => void; + }; + queue: CoordinatorQueue; + executor: { instance: CoordinatorExecutor; kicks: number }; + priceFeed: PriceFeed; + setOracleAnswer: (answer: bigint) => void; + fireAnswerUpdated: () => Promise; +} + +/** + * Builds the full predictive stack against in-memory stubs: + * - Chain stub: routes `readContract` and `multicall` to scripted handlers. + * - Tracker stub: only `onAdded` / `onChanged` / `size` are exercised. + * - Executor stub: counts `kick()` invocations. + * + * The test harness has scripted answers for each PME / venue method the + * snapshot reader and health reader call, so the predictor exercises its + * full path end-to-end without touching a real RPC. + */ +function buildHarness({ + balance, + perpNetQty, + perpEntry, + underwaterAtPrice, +}: { + balance: bigint; + perpNetQty: bigint; + perpEntry: bigint; + /** + * The fake on-chain `computePortfolioMM` returns `balance + 1` (i.e. 1 wei + * underwater) when the latest spot is at or below this price; otherwise + * `balance - 1` (1 wei healthy). Lets us script the planner to flip on a + * specific tick. + */ + underwaterAtPrice: bigint; +}): Wired { + // Mutable "current price" is what `latestRoundData` returns; the harness + // also uses it to decide what `computePortfolioMM` returns (above logic). + // Default $100 at 8 decimals → token-decimal (6) price = 100_000_000. + let oracleAnswer = 10_000_000_000n; + let onLogs: (() => void) | undefined; + + const chain = { + publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") return 8; + if (functionName === "latestRoundData") { + return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + watchContractEvent: ({ onLogs: cb }: { onLogs: () => void }) => { + onLogs = cb; + return () => { + onLogs = undefined; + }; + }, + multicall: async ({ contracts }: { contracts: readonly { functionName: string }[] }) => { + // The harness inspects `functionName` on each contract and assembles + // a matching response array. Snapshot-read calls and health-read + // calls share the same multicall path, so one handler covers both. + return contracts.map((c) => { + switch (c.functionName) { + case "imSpotShock": + return 10n ** 17n; + case "mmSpotShock": + return 5n * 10n ** 16n; + case "decimals": + return 6; + case "QUANTITY_DECIMALS": + return 6; + case "balanceOf": + return balance; + case "getUserPosition": + return { netQuantity: perpNetQty, aggregatedEntryPrice: perpEntry }; + case "getOrderMargin": + return 0n; + case "getPendingFunding": + return 0n; + case "getFuturesOrderMargin": + return 0n; + case "getPositionIds": + return []; + case "deliveryDurationDays": + return 30; + case "computePortfolioIM": + return balance / 2n; + case "computePortfolioMM": { + // Token-decimal current price: oracleAnswer / 100 (8 → 6 dec). + const currentPriceTokens = oracleAnswer / 100n; + return currentPriceTokens <= underwaterAtPrice ? balance + 1n : balance - 1n; + } + default: + throw new Error(`unexpected multicall functionName: ${c.functionName}`); + } + }); + }, + }, + } as unknown as Chain; + + const config = makeConfig(); + const queue = new CoordinatorQueue(); + + let kicks = 0; + const executor = { + instance: { kick: () => void kicks++ } as unknown as CoordinatorExecutor, + get kicks() { + return kicks; + }, + }; + + const addedListeners: TrackerListener[] = []; + const changedListeners: TrackerListener[] = []; + const tracker = { + instance: { + onAdded: (l: TrackerListener) => { + addedListeners.push(l); + return () => {}; + }, + onChanged: (l: TrackerListener) => { + changedListeners.push(l); + return () => {}; + }, + size: () => 1, + } as unknown as ParticipantTracker, + fireAdded: (user: Address) => addedListeners.forEach((l) => void l(user)), + fireChanged: (user: Address) => changedListeners.forEach((l) => void l(user)), + }; + + const priceFeed = new PriceFeed(chain, config, silentLogger, 6); + + return { + chain, + config, + tracker, + queue, + executor, + priceFeed, + setOracleAnswer: (answer) => { + oracleAnswer = answer; + }, + fireAnswerUpdated: async () => { + if (onLogs === undefined) throw new Error("watcher not registered"); + onLogs(); + // Allow the chain of `void this.refresh(...)` → listeners → async + // `enqueueCrossed` to settle. Two ticks is empirically enough. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }, + }; +} + +describe("predict/coordinator: end-to-end", () => { + it("on price drop crossing a user's threshold, enqueues them and kicks the executor", async () => { + // Long position with balance=$20, entry=$100, qty=1. mmShock=5%. + // Predicted liquidation price ≈ $84.21 (≈ 84_210_526 in 6-decimal tokens). + // Drop oracle from $100 → $80. + const harness = buildHarness({ + balance: 20_000_000n, + perpNetQty: 1n * 10n ** 6n, + perpEntry: 100_000_000n, + // Underwater whenever spot ≤ $84 → 84_000_000n (token decimals). + underwaterAtPrice: 84_000_000n, + }); + + await harness.priceFeed.start(); + const predictor = new PredictiveCoordinator( + harness.chain, + harness.config, + harness.tracker.instance, + harness.queue, + harness.executor.instance, + harness.priceFeed, + silentLogger, + ); + await predictor.start(); + + // Add the user — predictor will read their snapshot and index thresholds. + harness.tracker.fireAdded(USER); + // Let the rebuild run. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(predictor.size(), 1, "user should be indexed after rebuild"); + + // Drop oracle to $80 — well below the predicted threshold (~$84.21). + harness.setOracleAnswer(8_000_000_000n); + await harness.fireAnswerUpdated(); + + assert.equal(harness.queue.size(), 1, "user should land in the coordinator queue"); + assert.equal(harness.queue.peek()?.user, USER); + assert.ok(harness.executor.kicks >= 1, "executor should have been kicked"); + + predictor.stop(); + harness.priceFeed.stop(); + }); + + it("does not enqueue if on-chain mmSurplus is still healthy (model drift safety net)", async () => { + // Same setup but `underwaterAtPrice` is $50 — even though the predictor + // says we crossed at $84.21, the on-chain truth says still healthy. + const harness = buildHarness({ + balance: 20_000_000n, + perpNetQty: 1n * 10n ** 6n, + perpEntry: 100_000_000n, + underwaterAtPrice: 50_000_000n, + }); + await harness.priceFeed.start(); + const predictor = new PredictiveCoordinator( + harness.chain, + harness.config, + harness.tracker.instance, + harness.queue, + harness.executor.instance, + harness.priceFeed, + silentLogger, + ); + await predictor.start(); + + harness.tracker.fireAdded(USER); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + harness.setOracleAnswer(8_000_000_000n); + await harness.fireAnswerUpdated(); + + // Predictor crossed thresholds (the 1 RPC was spent), but the queue + // upsert dropped the snapshot since `mmSurplus >= 0`. + assert.equal(harness.queue.size(), 0); + + predictor.stop(); + harness.priceFeed.stop(); + }); + + it("respects priceMoveTriggerBps — sub-threshold ticks skip evaluation", async () => { + const harness = buildHarness({ + balance: 20_000_000n, + perpNetQty: 1n * 10n ** 6n, + perpEntry: 100_000_000n, + underwaterAtPrice: 84_000_000n, + }); + // Override config to require ≥ 100 bps move (1%). + harness.config.oracle.priceMoveTriggerBps = 100; + + await harness.priceFeed.start(); + const predictor = new PredictiveCoordinator( + harness.chain, + harness.config, + harness.tracker.instance, + harness.queue, + harness.executor.instance, + harness.priceFeed, + silentLogger, + ); + await predictor.start(); + harness.tracker.fireAdded(USER); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + // 0.5% move ($100 → $99.50) — below the 1% trigger. + harness.setOracleAnswer(9_950_000_000n); + await harness.fireAnswerUpdated(); + assert.equal(harness.queue.size(), 0); + assert.equal(harness.executor.kicks, 0); + + predictor.stop(); + harness.priceFeed.stop(); + }); +}); diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts new file mode 100644 index 0000000..894466e --- /dev/null +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -0,0 +1,254 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address } from "viem"; +import { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; +import { CoordinatorQueue } from "../../src/coordinator/queue.ts"; +import { PriceFeed } from "../../src/oracle/priceFeed.ts"; +import { Notifier, type WebhookPoster } from "../../src/alert/notifier.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; +import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; +import type { ParticipantTracker, TrackerListener } from "../../src/discovery/tracker.ts"; + +const HASHPRICE = "0x000000000000000000000000000000000000aa01" as Address; +const BTC_FEED = "0x000000000000000000000000000000000000aa02" as Address; +const VAULT = "0x000000000000000000000000000000000000aa03" as Address; +const PME = "0x000000000000000000000000000000000000aa04" as Address; +const PERPS = "0x000000000000000000000000000000000000aa05" as Address; +const FUTURES = "0x000000000000000000000000000000000000aa06" as Address; +const USER = "0x1111111111111111111111111111111111111111" as Address; +const silentLogger = pino({ level: "silent" }); + +interface Wired { + setOracleAnswer: (answer: bigint) => void; + fireAnswerUpdated: () => Promise; + fireAdded: (user: Address) => void; + posted: Array<{ url: string; payload: unknown }>; + predictor: PredictiveCoordinator; + priceFeed: PriceFeed; + notifier: Notifier; +} + +/** + * Same shape as `coordinator.test.ts` harness but the multicall handler + * returns IM in the warn/critical band so we can assert the alert path. + * + * `imAtPriceTokens(price)` computes the IM the on-chain `computePortfolioIM` + * would return for our long-1 contract @ entry $100 user, mirroring the + * off-chain math: imRequired = stress(P) + (entry - P) for P < entry. + * The harness drives both `imRequired` (alerts) and `mmRequired` (queue) + * off the same formula so the predictor-→ on-chain handoff is consistent. + */ +function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigint }): Wired { + let oracleAnswer = 10_000_000_000n; // $100 at 8 decimals + let onLogs: (() => void) | undefined; + const PERP_QTY_DECIMALS = 6n; + const TOKEN_DECIMALS = 6n; + const IM_SHOCK = 10n ** 17n; // 10% + + function imAtPriceTokens(P: bigint): bigint { + // stress for 1 contract long: |1e18| * 0.10e18 * P*1e12 / 1e36 / 1e12 = 0.10*P + const stress = (IM_SHOCK * P) / 10n ** 18n; + const loss = P < perpEntry ? perpEntry - P : 0n; + return stress + loss; + } + function mmAtPriceTokens(P: bigint): bigint { + const MM_SHOCK = 5n * 10n ** 16n; + const stress = (MM_SHOCK * P) / 10n ** 18n; + const loss = P < perpEntry ? perpEntry - P : 0n; + return stress + loss; + } + + const chain = { + publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") return 8; + if (functionName === "latestRoundData") { + return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + watchContractEvent: ({ onLogs: cb }: { onLogs: () => void }) => { + onLogs = cb; + return () => { + onLogs = undefined; + }; + }, + multicall: async ({ contracts }: { contracts: readonly { functionName: string }[] }) => { + const currentPrice = oracleAnswer / 100n; // 8→6 decimals + return contracts.map((c) => { + switch (c.functionName) { + case "imSpotShock": + return IM_SHOCK; + case "mmSpotShock": + return 5n * 10n ** 16n; + case "decimals": + return Number(TOKEN_DECIMALS); + case "QUANTITY_DECIMALS": + return Number(PERP_QTY_DECIMALS); + case "balanceOf": + return balance; + case "getUserPosition": + return { netQuantity: 1_000_000n, aggregatedEntryPrice: perpEntry }; + case "getOrderMargin": + return 0n; + case "getPendingFunding": + return 0n; + case "getFuturesOrderMargin": + return 0n; + case "getPositionIds": + return []; + case "deliveryDurationDays": + return 30; + case "computePortfolioIM": + return imAtPriceTokens(currentPrice); + case "computePortfolioMM": + return mmAtPriceTokens(currentPrice); + default: + throw new Error(`unexpected multicall: ${c.functionName}`); + } + }); + }, + }, + } as unknown as Chain; + + const config = { + oracle: { + hashpriceUsdcAddress: HASHPRICE, + btcUsdcFeedAddress: BTC_FEED, + priceMoveTriggerBps: 0, + }, + vault: { address: VAULT }, + pme: { address: PME }, + perps: { address: PERPS }, + futures: { address: FUTURES }, + alerts: { + webhookUrl: "https://example.test/hook", + dedupeMs: 60_000, + imWarnUtilization: 0.85, + imCriticalUtilization: 0.95, + }, + } as Config; + + const queue = new CoordinatorQueue(); + const executor = { kick: () => {} } as unknown as CoordinatorExecutor; + const addedListeners: TrackerListener[] = []; + const tracker = { + onAdded: (l: TrackerListener) => { + addedListeners.push(l); + return () => {}; + }, + onChanged: () => () => {}, + size: () => 1, + } as unknown as ParticipantTracker; + + const posted: Array<{ url: string; payload: unknown }> = []; + const poster: WebhookPoster = async (url, payload) => { + posted.push({ url, payload }); + }; + const notifier = new Notifier(config, silentLogger, { poster }); + const priceFeed = new PriceFeed(chain, config, silentLogger, 6); + const predictor = new PredictiveCoordinator( + chain, + config, + tracker, + queue, + executor, + priceFeed, + silentLogger, + notifier, + ); + + return { + setOracleAnswer: (answer) => { + oracleAnswer = answer; + }, + fireAnswerUpdated: async () => { + if (onLogs === undefined) throw new Error("watcher not registered"); + onLogs(); + // Three ticks: refresh → handlePriceUpdate → handleCrossings → + // notifier.drain. Each `await` settles one promise hop. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }, + fireAdded: (user: Address) => addedListeners.forEach((l) => void l(user)), + posted, + predictor, + priceFeed, + notifier, + }; +} + +async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); +} + +describe("predict/coordinator: predictive alerts", () => { + it("fires a critical alert when price crosses the predicted IM-critical threshold", async () => { + // Long, balance=$50, entry=$100. critDown ≈ $58.33. + const harness = buildHarness({ + balance: 50_000_000n, + perpEntry: 100_000_000n, + }); + await harness.priceFeed.start(); + await harness.predictor.start(); + harness.fireAdded(USER); + await settle(); + await settle(); + assert.equal(harness.predictor.critSize(), 1, "critIndex should hold the user"); + + // Drop spot to $55 — below crit ($58.3), still above liq. + harness.setOracleAnswer(5_500_000_000n); + await harness.fireAnswerUpdated(); + + assert.ok(harness.posted.length >= 1, `expected ≥1 alert posted, got ${harness.posted.length}`); + const payload = harness.posted[0]?.payload as { severity: string }; + assert.equal(payload.severity, "critical"); + + harness.predictor.stop(); + harness.priceFeed.stop(); + }); + + it("fires a warn alert (not critical) when price only crosses the warn threshold", async () => { + // Balance=$50, entry=$100. warnDown ≈ $63.9, critDown ≈ $58.3. + const harness = buildHarness({ + balance: 50_000_000n, + perpEntry: 100_000_000n, + }); + await harness.priceFeed.start(); + await harness.predictor.start(); + harness.fireAdded(USER); + await settle(); + await settle(); + assert.equal(harness.predictor.warnSize(), 1); + + // Drop to $62 — between warn and crit. + harness.setOracleAnswer(6_200_000_000n); + await harness.fireAnswerUpdated(); + + assert.ok(harness.posted.length >= 1, `expected ≥1 alert, got ${harness.posted.length}`); + const payload = harness.posted[0]?.payload as { severity: string }; + assert.equal(payload.severity, "warn"); + + harness.predictor.stop(); + harness.priceFeed.stop(); + }); + + it("does not fire alerts before the user is added (index empty)", async () => { + const harness = buildHarness({ balance: 50_000_000n, perpEntry: 100_000_000n }); + await harness.priceFeed.start(); + await harness.predictor.start(); + // No fireAdded() — index stays empty. + + harness.setOracleAnswer(5_500_000_000n); + await harness.fireAnswerUpdated(); + + assert.equal(harness.posted.length, 0); + + harness.predictor.stop(); + harness.priceFeed.stop(); + }); +}); diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts new file mode 100644 index 0000000..7ff5eb1 --- /dev/null +++ b/keeper/tests/predict/mm.test.ts @@ -0,0 +1,247 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { + imRequired, + imSurplus, + mmRequired, + mmSurplus, + netDeltaWad, + perpUnrealizedLoss, + futuresUnrealizedLoss, + stressLoss, +} from "../../src/predict/mm.ts"; +import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; + +const USER = "0x1111111111111111111111111111111111111111" as Address; + +const PARAMS: MMParams = { + imSpotShock: 10n ** 17n, // 0.10e18 = 10% + mmSpotShock: 5n * 10n ** 16n, // 0.05e18 = 5% + tokenDecimals: 6, + perpQuantityDecimals: 6, +}; + +const QTY_SCALE = 10n ** 6n; + +/** + * Skeleton with everything zeroed — tests override the bits they care about + * so each case stays focused on the math under test. + */ +function emptySnapshot(overrides: Partial = {}): AccountSnapshot { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, + futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + ...overrides, + }; +} + +describe("predict/mm: netDeltaWad", () => { + it("returns 0 for an idle account", () => { + assert.equal(netDeltaWad(emptySnapshot(), PARAMS), 0n); + }); + + it("converts a long perp position to WAD using qty decimals", () => { + // 1.5 contracts long → 1.5 * 1e18 = 1.5e18 WAD delta. + const snap = emptySnapshot({ + perp: { netQty: 1_500_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(netDeltaWad(snap, PARAMS), 1_500_000_000_000_000_000n); + }); + + it("subtracts a short perp position", () => { + const snap = emptySnapshot({ + perp: { netQty: -2_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(netDeltaWad(snap, PARAMS), -2_000_000_000_000_000_000n); + }); + + it("adds futures buyer delta scaled by deliveryDays", () => { + // Buyer of 1 contract over 30 days → +30 * 1e18 WAD delta. + const snap = emptySnapshot({ + futures: { + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + assert.equal(netDeltaWad(snap, PARAMS), 30n * 10n ** 18n); + }); + + it("subtracts futures seller delta", () => { + const snap = emptySnapshot({ + futures: { + positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n }], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + assert.equal(netDeltaWad(snap, PARAMS), -30n * 10n ** 18n); + }); + + it("sums perps + futures legs into one signed delta", () => { + const snap = emptySnapshot({ + perp: { netQty: 1_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, // +1e18 + futures: { + positions: [ + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n }, + { id: "0xbb", isBuyer: false, entryPricePerDay: 60n }, + ], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + // Perp +1e18; futures +30e18 - 30e18 = 0 → net = +1e18. + assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); + }); +}); + +describe("predict/mm: stressLoss", () => { + it("is 0 when delta is 0", () => { + assert.equal(stressLoss(0n, PARAMS.mmSpotShock, 100_000_000n, 6), 0n); + }); + + it("scales linearly with |delta|", () => { + const a = stressLoss(1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + const b = stressLoss(2n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + assert.equal(b, 2n * a); + }); + + it("scales linearly with shock", () => { + const a = stressLoss(1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + const b = stressLoss(1n * 10n ** 18n, PARAMS.imSpotShock, 100_000_000n, 6); // imSpotShock = 2x mmSpotShock + assert.equal(b, 2n * a); + }); + + it("uses |delta| (sign is irrelevant — worst-case scenario)", () => { + const long = stressLoss(1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + const short = stressLoss(-1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + assert.equal(short, long); + }); + + it("matches the closed-form |delta|*shock*P / (WAD * 10^(18-tokenDec))", () => { + // delta = 1e18, shock = 0.05e18, P = 100_000_000 (token dec 6 → $100), tokenDec = 6. + // stressWad = 1e18 * 0.05e18 * (100_000_000 * 1e12) / (1e18 * 1e18) = 5e15 WAD + // tokens = 5e15 / 1e12 = 5_000 (token dec) = $0.005 — wait, that's tiny. + // Let me recompute. P_wad = P * 10^(18 - tokenDec) = 100_000_000 * 1e12 = 1e20. + // stressWad = (1e18 * 0.05e18 * 1e20) / 1e36 = 1e20 * 0.05 = 5e18 WAD. + // tokens = 5e18 / 1e12 = 5_000_000 (token dec 6 = $5). + // 5% of $100 long position = $5. Correct. + const out = stressLoss(1n * 10n ** 18n, 5n * 10n ** 16n, 100_000_000n, 6); + assert.equal(out, 5_000_000n); + }); +}); + +describe("predict/mm: perpUnrealizedLoss", () => { + it("returns 0 for a flat user", () => { + assert.equal(perpUnrealizedLoss(emptySnapshot(), PARAMS, 100_000_000n), 0n); + }); + + it("returns 0 for a profitable long (P > entry)", () => { + const snap = emptySnapshot({ + perp: { netQty: 1_000_000n, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(perpUnrealizedLoss(snap, PARAMS, 110_000_000n), 0n); + }); + + it("returns the underwater amount for a long below entry (linear in price)", () => { + // 1 contract long at $100, P = $90 → loss = ($100 - $90) * 1 = $10. + const snap = emptySnapshot({ + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(perpUnrealizedLoss(snap, PARAMS, 90_000_000n), 10_000_000n); + }); + + it("returns the underwater amount for a short above entry", () => { + // 1 contract short at $100, P = $110 → loss = ($110 - $100) * 1 = $10. + const snap = emptySnapshot({ + perp: { netQty: -1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(perpUnrealizedLoss(snap, PARAMS, 110_000_000n), 10_000_000n); + }); +}); + +describe("predict/mm: futuresUnrealizedLoss", () => { + it("returns 0 with no positions", () => { + assert.equal(futuresUnrealizedLoss(emptySnapshot(), 100_000_000n), 0n); + }); + + it("buyer loses when P drops below entry; loss scales by deliveryDays", () => { + const snap = emptySnapshot({ + futures: { + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10 * 30 = -300. loss = 300. + assert.equal(futuresUnrealizedLoss(snap, 40n), 300n); + }); + + it("seller loses when P rises above entry", () => { + const snap = emptySnapshot({ + futures: { + positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n }], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + assert.equal(futuresUnrealizedLoss(snap, 60n), 300n); + }); + + it("sums losses across multiple positions; profitable legs do not net out", () => { + const snap = emptySnapshot({ + futures: { + positions: [ + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n }, // P=40 → loses 300 + { id: "0xbb", isBuyer: false, entryPricePerDay: 30n }, // P=40 → loses 300 + ], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + // Loss is sum of *losing* legs only (consistent with `max(0, -pnl)` per leg + // mirroring the on-chain `getFuturesUnrealizedPnl` aggregation, which + // would be 0 net but PME treats them piecewise via stress + per-leg PnL). + // Here both happen to be losing — buyer down, seller up. + assert.equal(futuresUnrealizedLoss(snap, 40n), 600n); + }); +}); + +describe("predict/mm: mmRequired / mmSurplus / imRequired / imSurplus", () => { + it("for an idle account, all four return only the constant add-ons", () => { + const snap = emptySnapshot({ + balance: 1_000n, + perp: { netQty: 0n, entryPrice: 0n, orderMargin: 100n, fundingOwed: 50n }, + futures: { positions: [], orderMargin: 25n, deliveryDays: 0n }, + }); + // No delta → no stress, no PnL. orderMargin + funding = 175. + assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 175n); + assert.equal(imRequired(snap, PARAMS, 100_000_000n), 175n); + assert.equal(mmSurplus(snap, PARAMS, 100_000_000n), 825n); + assert.equal(imSurplus(snap, PARAMS, 100_000_000n), 825n); + }); + + it("for a delta-only long, mmRequired equals stress and imRequired is strictly larger", () => { + const snap = emptySnapshot({ + balance: 0n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + // At entry price: no PnL. Pure stress contribution = $5 (mm) / $10 (im). + assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 5_000_000n); + assert.equal(imRequired(snap, PARAMS, 100_000_000n), 10_000_000n); + }); + + it("mmSurplus drops as price moves below a long's entry (PnL kicks in)", () => { + const snap = emptySnapshot({ + balance: 50_000_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const atEntry = mmSurplus(snap, PARAMS, 100_000_000n); + const below = mmSurplus(snap, PARAMS, 80_000_000n); + // Below entry: stress + perp PnL loss compound; surplus shrinks. + assert.ok(below < atEntry, `expected surplus(80) < surplus(100), got ${below} vs ${atEntry}`); + }); +}); diff --git a/keeper/tests/predict/predictiveIndex.test.ts b/keeper/tests/predict/predictiveIndex.test.ts new file mode 100644 index 0000000..7db969b --- /dev/null +++ b/keeper/tests/predict/predictiveIndex.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { PredictiveIndex } from "../../src/predict/predictiveIndex.ts"; + +function userAt(idx: number): Address { + return `0x${(idx + 1).toString(16).padStart(40, "0")}` as Address; +} + +describe("predict/predictiveIndex: upsert / invalidate / size", () => { + it("ignores upserts where both thresholds are undefined", () => { + const idx = new PredictiveIndex(); + const tracked = idx.upsert({ user: userAt(0), liqDown: undefined, liqUp: undefined }); + assert.equal(tracked, false); + assert.equal(idx.size(), 0); + }); + + it("stores users with at least one defined threshold", () => { + const idx = new PredictiveIndex(); + assert.equal(idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }), true); + assert.equal(idx.upsert({ user: userAt(1), liqDown: undefined, liqUp: 200n }), true); + assert.equal(idx.size(), 2); + }); + + it("upsert replaces (not duplicates) by user address", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); + idx.upsert({ user: userAt(0), liqDown: 90n, liqUp: undefined }); + assert.equal(idx.size(), 1); + assert.deepEqual(idx.get(userAt(0)), { user: userAt(0), liqDown: 90n, liqUp: undefined }); + }); + + it("invalidate removes the user from both sorted lists", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: 200n }); + idx.invalidate(userAt(0)); + assert.equal(idx.size(), 0); + // Subsequent crossings should find nothing. + assert.deepEqual(idx.crossings(150n, 50n), []); + }); +}); + +describe("predict/predictiveIndex: crossings on price drop", () => { + it("returns nothing on the very first tick (prev=undefined)", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); + assert.deepEqual(idx.crossings(undefined, 50n), []); + }); + + it("returns nothing when price doesn't move", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); + assert.deepEqual(idx.crossings(120n, 120n), []); + }); + + it("fires DOWN crossings for every user whose liqDown ∈ [next, prev]", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); // crossed + idx.upsert({ user: userAt(1), liqDown: 90n, liqUp: undefined }); // crossed + idx.upsert({ user: userAt(2), liqDown: 80n, liqUp: undefined }); // not crossed (below `next`) + idx.upsert({ user: userAt(3), liqDown: 110n, liqUp: undefined }); // already triggered before `prev` + + const out = idx.crossings(105n, 85n); + assert.equal(out.length, 2); + const users = new Set(out.map((c) => c.user)); + assert.ok(users.has(userAt(0))); + assert.ok(users.has(userAt(1))); + for (const c of out) assert.equal(c.direction, "down"); + }); + + it("inclusive bound — landing exactly on a threshold counts as crossed", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); + const out = idx.crossings(105n, 100n); + assert.equal(out.length, 1); + }); + + it("ignores upSorted entries during a price drop", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: undefined, liqUp: 90n }); // up only + assert.deepEqual(idx.crossings(105n, 80n), []); + }); +}); + +describe("predict/predictiveIndex: crossings on price rise", () => { + it("fires UP crossings for every user whose liqUp ∈ [prev, next]", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: undefined, liqUp: 100n }); // crossed + idx.upsert({ user: userAt(1), liqDown: undefined, liqUp: 110n }); // crossed + idx.upsert({ user: userAt(2), liqDown: undefined, liqUp: 120n }); // not crossed (above `next`) + idx.upsert({ user: userAt(3), liqDown: undefined, liqUp: 90n }); // already triggered + + const out = idx.crossings(95n, 115n); + assert.equal(out.length, 2); + const users = new Set(out.map((c) => c.user)); + assert.ok(users.has(userAt(0))); + assert.ok(users.has(userAt(1))); + for (const c of out) assert.equal(c.direction, "up"); + }); + + it("ignores downSorted entries during a price rise", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 110n, liqUp: undefined }); // down only + assert.deepEqual(idx.crossings(95n, 120n), []); + }); +}); + +describe("predict/predictiveIndex: snapshot", () => { + it("returns all tracked thresholds", () => { + const idx = new PredictiveIndex(); + idx.upsert({ user: userAt(0), liqDown: 100n, liqUp: undefined }); + idx.upsert({ user: userAt(1), liqDown: undefined, liqUp: 200n }); + const snap = idx.snapshot(); + assert.equal(snap.length, 2); + }); +}); diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts new file mode 100644 index 0000000..695e055 --- /dev/null +++ b/keeper/tests/predict/snapshot.test.ts @@ -0,0 +1,156 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { readAccountSnapshot, readMMParams } from "../../src/predict/snapshot.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const VAULT = "0x000000000000000000000000000000000000aa01" as Address; +const PME = "0x000000000000000000000000000000000000aa02" as Address; +const PERPS = "0x000000000000000000000000000000000000aa03" as Address; +const FUTURES = "0x000000000000000000000000000000000000aa04" as Address; +const USER = "0x1111111111111111111111111111111111111111" as Address; +const BUYER_POS_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SELLER_POS_ID = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function makeConfig(): Config { + return { + vault: { address: VAULT }, + pme: { address: PME }, + perps: { address: PERPS }, + futures: { address: FUTURES }, + } as Config; +} + +/** + * Builds a chain stub with scripted multicall responses keyed on + * `functionName` — same pattern as the coordinator harness, kept local so + * each test reads as a self-contained record of the on-chain shape it + * exercises. + */ +function makeChain(scripted: { + futuresPositionIds?: readonly string[]; + futuresPositions?: Record; + perpNetQty?: bigint; + perpEntry?: bigint; + perpOrderMargin?: bigint; + perpFunding?: bigint; + futuresOrderMargin?: bigint; + balance?: bigint; + imShock?: bigint; + mmShock?: bigint; + tokenDecimals?: number; + perpQtyDecimals?: number; + deliveryDays?: number; +}): Chain { + return { + publicClient: { + multicall: async ({ + contracts, + }: { + contracts: readonly { functionName: string; args?: readonly unknown[] }[]; + }) => { + return contracts.map((c) => { + switch (c.functionName) { + case "balanceOf": + return scripted.balance ?? 0n; + case "getUserPosition": + return { + netQuantity: scripted.perpNetQty ?? 0n, + aggregatedEntryPrice: scripted.perpEntry ?? 0n, + }; + case "getOrderMargin": + return scripted.perpOrderMargin ?? 0n; + case "getPendingFunding": + return scripted.perpFunding ?? 0n; + case "getFuturesOrderMargin": + return scripted.futuresOrderMargin ?? 0n; + case "getPositionIds": + return scripted.futuresPositionIds ?? []; + case "deliveryDurationDays": + return scripted.deliveryDays ?? 30; + case "getPositionById": { + const id = c.args?.[0] as string; + const pos = scripted.futuresPositions?.[id]; + if (pos === undefined) throw new Error(`unscripted position ${id}`); + return pos; + } + case "imSpotShock": + return scripted.imShock ?? 10n ** 17n; + case "mmSpotShock": + return scripted.mmShock ?? 5n * 10n ** 16n; + case "decimals": + return scripted.tokenDecimals ?? 6; + case "QUANTITY_DECIMALS": + return scripted.perpQtyDecimals ?? 6; + default: + throw new Error(`unscripted call: ${c.functionName}`); + } + }); + }, + }, + } as unknown as Chain; +} + +describe("predict/snapshot: readMMParams", () => { + it("returns the engine-wide constants in one multicall", async () => { + const params = await readMMParams(makeChain({}), makeConfig()); + assert.equal(params.imSpotShock, 10n ** 17n); + assert.equal(params.mmSpotShock, 5n * 10n ** 16n); + assert.equal(params.tokenDecimals, 6); + assert.equal(params.perpQuantityDecimals, 6); + }); +}); + +describe("predict/snapshot: readAccountSnapshot", () => { + it("returns a flat snapshot for a fresh user with no positions or orders", async () => { + const chain = makeChain({ balance: 0n }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + assert.equal(snap.user, USER); + assert.equal(snap.balance, 0n); + assert.equal(snap.perp.netQty, 0n); + assert.equal(snap.perp.fundingOwed, 0n); + assert.equal(snap.futures.positions.length, 0); + assert.equal(snap.futures.deliveryDays, 30n); + }); + + it("clamps pending funding to >= 0 (PME treats credits as not-owed)", async () => { + const chain = makeChain({ perpFunding: -5n }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + assert.equal(snap.perp.fundingOwed, 0n); + }); + + it("preserves positive funding owed", async () => { + const chain = makeChain({ perpFunding: 1_000n }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + assert.equal(snap.perp.fundingOwed, 1_000n); + }); + + it("hydrates futures positions and assigns isBuyer based on the buyer field", async () => { + const chain = makeChain({ + futuresPositionIds: [BUYER_POS_ID, SELLER_POS_ID], + futuresPositions: { + [BUYER_POS_ID]: { + buyer: USER, + seller: "0x000000000000000000000000000000000000feed", + buyPricePerDay: 50n, + sellPricePerDay: 51n, + }, + [SELLER_POS_ID]: { + buyer: "0x000000000000000000000000000000000000feed", + seller: USER, + buyPricePerDay: 60n, + sellPricePerDay: 59n, + }, + }, + }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + assert.equal(snap.futures.positions.length, 2); + const buyer = snap.futures.positions.find((p) => p.id === BUYER_POS_ID); + const seller = snap.futures.positions.find((p) => p.id === SELLER_POS_ID); + assert.equal(buyer?.isBuyer, true); + assert.equal(buyer?.entryPricePerDay, 50n); + assert.equal(seller?.isBuyer, false); + assert.equal(seller?.entryPricePerDay, 59n); + }); +}); diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts new file mode 100644 index 0000000..63c2189 --- /dev/null +++ b/keeper/tests/predict/solve.test.ts @@ -0,0 +1,266 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { solveAlertThresholds, solveLiquidationThresholds } from "../../src/predict/solve.ts"; +import { imRequired, mmSurplus } from "../../src/predict/mm.ts"; +import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; + +const USER = "0x1111111111111111111111111111111111111111" as Address; +const QTY_SCALE = 10n ** 6n; + +const PARAMS: MMParams = { + imSpotShock: 10n ** 17n, // 10% + mmSpotShock: 5n * 10n ** 16n, // 5% + tokenDecimals: 6, + perpQuantityDecimals: 6, +}; + +function emptySnapshot(overrides: Partial = {}): AccountSnapshot { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, + futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + ...overrides, + }; +} + +/** + * Sanity-check helper: a crossing threshold should sit on the boundary of + * the safe region. We don't require `mmSurplus(threshold) === 0` exactly + * (the bisector rounds to integer wei, and `mmRequired` is a sum of + * floor-divided terms, so a few wei of slop is structural), but we do + * require the threshold to be a true crossing — surplus is ≥ 0 on the + * safe side at-or-near the threshold and surplus moves further negative + * as the price moves toward the unsafe side. + */ +function assertCrossing( + snap: AccountSnapshot, + params: MMParams, + threshold: bigint, + side: "down" | "up", +): void { + const sAt = mmSurplus(snap, params, threshold); + assert.ok(sAt >= 0n, `expected surplus(${threshold}) ≥ 0 (safe side), got ${sAt}`); + if (side === "down") { + // Going further down should not increase surplus. + const sFurther = mmSurplus(snap, params, threshold - 1n); + assert.ok( + sFurther <= sAt, + `expected surplus(${threshold - 1n}) ≤ surplus(${threshold}) on down-side`, + ); + } else { + const sFurther = mmSurplus(snap, params, threshold + 1n); + assert.ok( + sFurther <= sAt, + `expected surplus(${threshold + 1n}) ≤ surplus(${threshold}) on up-side`, + ); + } +} + +describe("predict/solve: solveLiquidationThresholds", () => { + it("returns no thresholds when the user is currently underwater", () => { + // Long with no balance → already underwater at any reasonable price. + const snap = emptySnapshot({ + balance: 0n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + assert.equal(out.liqDown, undefined); + assert.equal(out.liqUp, undefined); + }); + + it("returns no thresholds for a flat user — they're never liquidatable", () => { + const snap = emptySnapshot({ balance: 1_000_000_000n }); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + assert.equal(out.liqDown, undefined); + assert.equal(out.liqUp, undefined); + }); + + it("finds a downside threshold for a leveraged net-long perp position", () => { + // 1 contract long @ $100, balance $20. Stress 5%, so at entry stress = $5. + // Below entry, every $1 drop adds $1 PnL loss. Net mmRequired below entry: + // stress(P) + (entry - P) = 0.05 * P + (100 - P) = 100 - 0.95 P + // surplus(P) = 20 - (100 - 0.95 P) = -80 + 0.95 P + // crosses 0 at P = 80 / 0.95 ≈ 84.21 + const snap = emptySnapshot({ + balance: 20_000_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + assert.notEqual(out.liqDown, undefined); + if (out.liqDown !== undefined) { + // ~$84.21M (token decimals → 84_210_526n give-or-take). + assert.ok( + out.liqDown > 84_000_000n && out.liqDown < 85_000_000n, + `expected liqDown ≈ 84.2 * 10^6, got ${out.liqDown}`, + ); + assertCrossing(snap, PARAMS, out.liqDown, "down"); + } + }); + + it("finds an upside threshold for a leveraged net-short perp position", () => { + const snap = emptySnapshot({ + balance: 20_000_000n, + perp: { netQty: -1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + assert.notEqual(out.liqUp, undefined); + if (out.liqUp !== undefined) { + // Mirror of the long case: ~$117.65 ((entry + balance) / (1 - mmShock)). + // Above entry: stress(P) + (P - entry) = 0.05P + P - 100 = 1.05P - 100 + // surplus(P) = 20 - (1.05P - 100) = 120 - 1.05P. Zero at 120/1.05 ≈ 114.29. + assert.ok( + out.liqUp > 113_000_000n && out.liqUp < 116_000_000n, + `expected liqUp ≈ 114.3 * 10^6, got ${out.liqUp}`, + ); + assertCrossing(snap, PARAMS, out.liqUp, "up"); + } + }); + + it("returns BOTH thresholds when balance is small relative to stress + position", () => { + // Net-long, but balance high enough that stress alone (no PnL) eventually + // eats it on the way up too. Above entry: surplus(P) = balance - stress(P) + // = balance - 0.05P. Crosses zero at P = balance / 0.05 = 20*$1M / 0.05 = $400M. + const snap = emptySnapshot({ + balance: 20_000_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + assert.notEqual(out.liqUp, undefined); + if (out.liqUp !== undefined) { + assert.ok( + out.liqUp > 390_000_000n && out.liqUp < 410_000_000n, + `expected liqUp ≈ $400M, got ${out.liqUp}`, + ); + assertCrossing(snap, PARAMS, out.liqUp, "up"); + } + }); + + it("handles a futures buyer position the same way as a long perp", () => { + // Buyer of 1 contract over 30 days @ $50/day, balance $200. + // Below entry: stress + (entry - P) * 30 days + // delta = 30 * WAD; stress = |delta| * shock * P / WAD² → 30 * 0.05 * P / 1 = 1.5 P (per token decimals). + // Hmm — let me just verify via mmSurplus at the returned threshold. + const snap = emptySnapshot({ + balance: 200n, + futures: { + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + orderMargin: 0n, + deliveryDays: 30n, + }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 50n); + assert.notEqual(out.liqDown, undefined); + if (out.liqDown !== undefined) { + assertCrossing(snap, PARAMS, out.liqDown, "down"); + } + }); + + it("threshold tightens when orderMargin and fundingOwed eat balance headroom", () => { + const base = emptySnapshot({ + balance: 20_000_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const withDrag = emptySnapshot({ + balance: 20_000_000n, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orderMargin: 5_000_000n, + fundingOwed: 1_000_000n, + }, + }); + const baseLiq = solveLiquidationThresholds(base, PARAMS, 100_000_000n).liqDown; + const dragLiq = solveLiquidationThresholds(withDrag, PARAMS, 100_000_000n).liqDown; + assert.notEqual(baseLiq, undefined); + assert.notEqual(dragLiq, undefined); + if (baseLiq !== undefined && dragLiq !== undefined) { + // Less headroom → liquidation triggers at a higher price. + assert.ok( + dragLiq > baseLiq, + `expected drag liqDown (${dragLiq}) > base liqDown (${baseLiq})`, + ); + } + }); +}); + +describe("predict/solve: solveAlertThresholds", () => { + // ppm scaling matches `computeUtilization`. + const WARN_PPM = 850_000n; // 85% + const CRIT_PPM = 950_000n; // 95% + + it("returns all-undefined for a flat user (no IM utilization possible)", () => { + const snap = emptySnapshot({ balance: 1_000_000_000n }); + const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + assert.equal(out.warnDown, undefined); + assert.equal(out.warnUp, undefined); + assert.equal(out.critDown, undefined); + assert.equal(out.critUp, undefined); + }); + + it("returns all-undefined when balance is zero (utilization undefined)", () => { + const snap = emptySnapshot({ + balance: 0n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + assert.equal(out.warnDown, undefined); + assert.equal(out.critDown, undefined); + }); + + it("warn threshold sits ABOVE liquidation threshold for a long going underwater", () => { + // Long with $50 of collateral, $100 entry — both alert and liq + // crossings exist on the downside (user is liquidatable around $52.6). + const snap = emptySnapshot({ + balance: 50_000_000n, // $50 collateral + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const liq = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + const alerts = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + assert.notEqual(liq.liqDown, undefined); + assert.notEqual(alerts.warnDown, undefined); + assert.notEqual(alerts.critDown, undefined); + if (liq.liqDown !== undefined && alerts.warnDown !== undefined && alerts.critDown !== undefined) { + // warn should fire first (higher price), then crit, then liquidation. + assert.ok( + alerts.warnDown > alerts.critDown, + `warn (${alerts.warnDown}) should be above crit (${alerts.critDown})`, + ); + assert.ok( + alerts.critDown > liq.liqDown, + `crit (${alerts.critDown}) should be above liq (${liq.liqDown})`, + ); + } + }); + + it("at the warn threshold, imRequired ≈ warnUtil * balance", () => { + const snap = emptySnapshot({ + balance: 50_000_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const alerts = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + if (alerts.warnDown !== undefined) { + const target = (WARN_PPM * snap.balance) / 1_000_000n; + const im = imRequired(snap, PARAMS, alerts.warnDown); + const slop = im > target ? im - target : target - im; + // 0.1% of target — bisection on integer wei rounds; this gives a + // generous tolerance without hiding real solver bugs. + assert.ok( + slop < target / 1_000n, + `imRequired(${alerts.warnDown}) = ${im}, target = ${target}, slop = ${slop}`, + ); + } + }); + + it("returns undefined for a level the user is already past at currentPrice", () => { + // Long with tiny balance — already over both warn and crit at current. + const snap = emptySnapshot({ + balance: 1_000n, + perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + }); + const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + assert.equal(out.warnDown, undefined); + assert.equal(out.critDown, undefined); + }); +}); diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts new file mode 100644 index 0000000..08bda3f --- /dev/null +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -0,0 +1,194 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import { Healthcheck } from "../../src/runtime/healthcheck.ts"; +import type { Config } from "../../src/config.ts"; +import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; +import type { CoordinatorQueue } from "../../src/coordinator/queue.ts"; +import type { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import type { PriceFeed } from "../../src/oracle/priceFeed.ts"; +import type { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; + +const silentLogger = pino({ level: "silent" }); + +interface Knobs { + executorRunning: boolean; + trackedUsers: number; + inflight: number; + queueDepth: number; + predictedUsers?: number; + predictorInflight?: number; + currentPrice?: bigint; +} + +function makeStubs(knobs: Knobs) { + const config = { runtime: { healthPort: 0 } } as Config; // 0 = ephemeral port + const tracker = { size: () => knobs.trackedUsers } as unknown as ParticipantTracker; + const executor = { + isRunning: () => knobs.executorRunning, + inflightCount: () => knobs.inflight, + } as unknown as CoordinatorExecutor; + const queue = { size: () => knobs.queueDepth } as unknown as CoordinatorQueue; + const predictor = + knobs.predictedUsers !== undefined + ? ({ + size: () => knobs.predictedUsers ?? 0, + warnSize: () => 0, + critSize: () => 0, + inflight: () => knobs.predictorInflight ?? 0, + } as unknown as PredictiveCoordinator) + : undefined; + const priceFeed = + knobs.currentPrice !== undefined + ? ({ current: () => knobs.currentPrice } as unknown as PriceFeed) + : undefined; + return { config, tracker, executor, queue, predictor, priceFeed }; +} + +/** Reads the listening port back off the underlying http.Server. */ +function portOf(hc: Healthcheck): number { + const srv = (hc as unknown as { server: { address(): { port: number } } }).server; + return srv.address().port; +} + +describe("runtime/healthcheck: snapshot", () => { + it("includes the predictor + priceFeed when wired", () => { + const { config, tracker, executor, queue, predictor, priceFeed } = makeStubs({ + executorRunning: true, + trackedUsers: 3, + inflight: 0, + queueDepth: 1, + predictedUsers: 2, + predictorInflight: 1, + currentPrice: 100_000_000n, + }); + const hc = new Healthcheck(config, tracker, executor, queue, silentLogger, predictor, priceFeed); + const snap = hc.snapshot(); + assert.equal(snap.executorRunning, 1); + assert.equal(snap.trackedUsers, 3); + assert.equal(snap.queueDepth, 1); + assert.equal(snap.predictedUsers, 2); + assert.equal(snap.predictorInflight, 1); + assert.equal(snap.currentPrice, "100000000"); + }); + + it("zero-fills predictor metrics when not wired (legacy boot path)", () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: false, + trackedUsers: 0, + inflight: 0, + queueDepth: 0, + }); + const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + const snap = hc.snapshot(); + assert.equal(snap.executorRunning, 0); + assert.equal(snap.predictedUsers, 0); + assert.equal(snap.predictorInflight, 0); + assert.equal(snap.currentPrice, null); + }); +}); + +/** + * `before/after` hooks would leak the http server when an assertion + * fails before `after` runs (event loop never drains, suite hangs). + * Use a small `withServer` helper instead so each test owns its + * setup/teardown via try/finally. + */ +async function withServer( + fn: (port: number) => Promise, +): Promise { + const { config, tracker, executor, queue, predictor, priceFeed } = makeStubs({ + executorRunning: true, + trackedUsers: 5, + inflight: 0, + queueDepth: 2, + predictedUsers: 4, + predictorInflight: 0, + currentPrice: 250_000_000n, + }); + const hc = new Healthcheck(config, tracker, executor, queue, silentLogger, predictor, priceFeed); + hc.start(); + try { + return await fn(portOf(hc)); + } finally { + await hc.stop(); + } +} + +describe("runtime/healthcheck: HTTP endpoints", () => { + it("GET /health returns 200 with the snapshot when executor is running", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.equal(body.status, "ok"); + assert.equal(body.trackedUsers, 5); + assert.equal(body.predictedUsers, 4); + assert.equal(body.currentPrice, "250000000"); + }); + }); + + it("GET /metrics returns Prometheus exposition with keeper_ prefix", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/metrics`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/plain/); + const body = await res.text(); + assert.match(body, /keeper_executor_running 1/); + assert.match(body, /keeper_tracked_users 5/); + assert.match(body, /keeper_queue_depth 2/); + assert.match(body, /keeper_predicted_users 4/); + assert.match(body, /keeper_oracle_price_token 250000000/); + }); + }); + + it("GET to an unknown path returns 404", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/nope`); + assert.equal(res.status, 404); + }); + }); +}); + +describe("runtime/healthcheck: degraded executor", () => { + it("returns 503 when the executor is stopped", async () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: false, + trackedUsers: 0, + inflight: 0, + queueDepth: 0, + }); + const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + hc.start(); + const port = portOf(hc); + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(res.status, 503); + const body = (await res.json()) as Record; + assert.equal(body.status, "degraded"); + } finally { + await hc.stop(); + } + }); + + it("/metrics omits keeper_oracle_price_token when the feed is uninitialised", async () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: true, + trackedUsers: 0, + inflight: 0, + queueDepth: 0, + predictedUsers: 0, + }); + // No priceFeed provided → snapshot returns currentPrice: null. + const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + hc.start(); + const port = portOf(hc); + try { + const res = await fetch(`http://127.0.0.1:${port}/metrics`); + const body = await res.text(); + assert.doesNotMatch(body, /keeper_oracle_price_token/); + } finally { + await hc.stop(); + } + }); +}); diff --git a/keeper/tests/runtime/scheduler.test.ts b/keeper/tests/runtime/scheduler.test.ts new file mode 100644 index 0000000..29a5e83 --- /dev/null +++ b/keeper/tests/runtime/scheduler.test.ts @@ -0,0 +1,281 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress, type Address } from "viem"; +import type pino from "pino"; +import { Scheduler } from "../../src/runtime/scheduler.ts"; +import { CoordinatorQueue } from "../../src/coordinator/queue.ts"; +import { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import { Notifier, type Alert, type WebhookPoster } from "../../src/alert/notifier.ts"; +import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +function userAt(idx: number): Address { + return getAddress(`0x${(idx + 1).toString(16).padStart(40, "0")}` as Address); +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +function makeConfig(opts: { warn?: number; critical?: number } = {}): Config { + return { + chain: { discoveryMode: "events" }, + vault: { address: userAt(100) }, + perps: { address: userAt(101) }, + futures: { address: userAt(102) }, + pme: { address: userAt(103) }, + alerts: { + webhookUrl: "https://hooks/x", + dedupeMs: 60_000, + imWarnUtilization: opts.warn ?? 0.85, + imCriticalUtilization: opts.critical ?? 0.95, + }, + runtime: { sweepIntervalMs: 1_000_000 }, + } as Config; +} + +/** + * Stubs out only the multicall + readContract paths the scheduler needs. + * `healthScript` returns the per-user (balance, im, mm) triples in the order + * the multicall is built. + */ +function makeChain(opts: { healthScript: Array<{ balance: bigint; im: bigint; mm: bigint }> }): Chain { + return { + publicClient: { + multicall: async ({ contracts }: { contracts: readonly unknown[] }) => { + const userCount = contracts.length / 3; + assert.equal(userCount, opts.healthScript.length, "script length matches user count"); + return opts.healthScript.flatMap((h) => [h.balance, h.im, h.mm]); + }, + readContract: async () => [], + watchContractEvent: () => () => undefined, + }, + } as unknown as Chain; +} + +function makeKickableExecutor(): { executor: CoordinatorExecutor; kicks: number } { + let kicks = 0; + const executor = { + kick: () => { + kicks++; + }, + isRunning: () => true, + inflightCount: () => 0, + } as unknown as CoordinatorExecutor; + return { + executor, + get kicks() { + return kicks; + }, + }; +} + +function makeRecordingPoster(): { poster: WebhookPoster; sent: Alert["severity"][] } { + const sent: Alert["severity"][] = []; + const poster: WebhookPoster = async (_url, payload) => { + sent.push((payload as { severity: Alert["severity"] }).severity); + }; + return { poster, sent }; +} + +describe("Scheduler.runSweep: alert ladder", () => { + it("fires `critical` for IM utilization ≥ critical threshold", async () => { + // balance=1000, im=950 → util 0.95 (== critical) + const config = makeConfig({ warn: 0.85, critical: 0.95 }); + const chain = makeChain({ healthScript: [{ balance: 1000n, im: 950n, mm: 800n }] }); + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.add(userAt(0)); + const queue = new CoordinatorQueue(); + const { poster, sent } = makeRecordingPoster(); + const notifier = new Notifier(config, silentLogger, { poster }); + const { executor } = makeKickableExecutor(); + const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, silentLogger); + + await scheduler.runSweep(); + + assert.deepEqual(sent, ["critical"]); + }); + + it("fires `warn` for warn ≤ utilization < critical", async () => { + const config = makeConfig({ warn: 0.85, critical: 0.95 }); + const chain = makeChain({ healthScript: [{ balance: 1000n, im: 900n, mm: 800n }] }); // util=0.9 + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.add(userAt(0)); + const { poster, sent } = makeRecordingPoster(); + const notifier = new Notifier(config, silentLogger, { poster }); + const { executor } = makeKickableExecutor(); + const scheduler = new Scheduler( + chain, + config, + tracker, + new CoordinatorQueue(), + executor, + notifier, + silentLogger, + ); + + await scheduler.runSweep(); + assert.deepEqual(sent, ["warn"]); + }); + + it("does not alert when utilization is below the warn threshold", async () => { + const config = makeConfig({ warn: 0.85, critical: 0.95 }); + const chain = makeChain({ healthScript: [{ balance: 1000n, im: 800n, mm: 700n }] }); // util=0.8 + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.add(userAt(0)); + const { poster, sent } = makeRecordingPoster(); + const notifier = new Notifier(config, silentLogger, { poster }); + const { executor } = makeKickableExecutor(); + const scheduler = new Scheduler( + chain, + config, + tracker, + new CoordinatorQueue(), + executor, + notifier, + silentLogger, + ); + + await scheduler.runSweep(); + assert.deepEqual(sent, []); + }); +}); + +describe("Scheduler.runSweep: queue + executor wiring", () => { + it("only enqueues underwater users — healthy ones are filtered by the queue", async () => { + const config = makeConfig(); + const chain = makeChain({ + healthScript: [ + { balance: 1000n, im: 100n, mm: 200n }, // healthy + { balance: 500n, im: 100n, mm: 700n }, // under + { balance: 200n, im: 100n, mm: 800n }, // most-under + ], + }); + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.addBatch([userAt(0), userAt(1), userAt(2)]); + const queue = new CoordinatorQueue(); + const notifier = new Notifier(config, silentLogger, { poster: async () => undefined }); + const { executor } = makeKickableExecutor(); + const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, silentLogger); + + await scheduler.runSweep(); + + assert.equal(queue.size(), 2, "healthy user dropped, only the two underwater enqueued"); + assert.equal(queue.pop()?.user, userAt(2), "most-underwater first"); + assert.equal(queue.pop()?.user, userAt(1)); + }); + + it("a recovered user is removed from the queue on the next sweep", async () => { + const config = makeConfig(); + const tracker = new ParticipantTracker(makeChain({ healthScript: [] }), config, silentLogger); + tracker.add(userAt(0)); + const queue = new CoordinatorQueue(); + const notifier = new Notifier(config, silentLogger, { poster: async () => undefined }); + const { executor } = makeKickableExecutor(); + + // First sweep — user is underwater. + let scheduler = new Scheduler( + makeChain({ healthScript: [{ balance: 100n, im: 100n, mm: 200n }] }), + config, + tracker, + queue, + executor, + notifier, + silentLogger, + ); + await scheduler.runSweep(); + assert.equal(queue.size(), 1); + + // Second sweep — user recovered (deposit landed, price moved, etc.). + scheduler = new Scheduler( + makeChain({ healthScript: [{ balance: 1000n, im: 100n, mm: 100n }] }), + config, + tracker, + queue, + executor, + notifier, + silentLogger, + ); + await scheduler.runSweep(); + assert.equal(queue.size(), 0, "healthy upsert removes the user from the queue"); + }); + + it("kicks the executor only when at least one underwater user is found", async () => { + const config = makeConfig(); + const chain = makeChain({ + healthScript: [{ balance: 1000n, im: 100n, mm: 200n }], // healthy + }); + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.add(userAt(0)); + const tracking = makeKickableExecutor(); + const notifier = new Notifier(config, silentLogger, { poster: async () => undefined }); + const scheduler = new Scheduler( + chain, + config, + tracker, + new CoordinatorQueue(), + tracking.executor, + notifier, + silentLogger, + ); + await scheduler.runSweep(); + assert.equal(tracking.kicks, 0, "no kick when nobody is underwater"); + }); + + it("kicks the executor when at least one user is underwater", async () => { + const config = makeConfig(); + const chain = makeChain({ + healthScript: [{ balance: 100n, im: 100n, mm: 200n }], // mmSurplus = -100 + }); + const tracker = new ParticipantTracker(chain, config, silentLogger); + tracker.add(userAt(0)); + const tracking = makeKickableExecutor(); + const notifier = new Notifier(config, silentLogger, { poster: async () => undefined }); + const scheduler = new Scheduler( + chain, + config, + tracker, + new CoordinatorQueue(), + tracking.executor, + notifier, + silentLogger, + ); + await scheduler.runSweep(); + assert.equal(tracking.kicks, 1); + }); + + it("is a no-op (no multicall, no kick) when the tracker is empty", async () => { + const config = makeConfig(); + let multicallCalls = 0; + const chain = { + publicClient: { + multicall: async () => { + multicallCalls++; + return []; + }, + readContract: async () => [], + watchContractEvent: () => () => undefined, + }, + } as unknown as Chain; + const tracker = new ParticipantTracker(chain, config, silentLogger); + const tracking = makeKickableExecutor(); + const notifier = new Notifier(config, silentLogger, { poster: async () => undefined }); + const scheduler = new Scheduler( + chain, + config, + tracker, + new CoordinatorQueue(), + tracking.executor, + notifier, + silentLogger, + ); + await scheduler.runSweep(); + assert.equal(multicallCalls, 0); + assert.equal(tracking.kicks, 0); + }); +}); diff --git a/keeper/tests/tx/liquidate.test.ts b/keeper/tests/tx/liquidate.test.ts new file mode 100644 index 0000000..d4c9f58 --- /dev/null +++ b/keeper/tests/tx/liquidate.test.ts @@ -0,0 +1,297 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import { + AbiFunctionNotFoundError, + BaseError, + ContractFunctionRevertedError, + encodeEventTopics, + encodeAbiParameters, + parseAbi, + type Abi, + type Address, + type TransactionReceipt, +} from "viem"; +import { __testing, sendLiquidate } from "../../src/tx/liquidate.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const silentLogger = pino({ level: "silent" }); + +const VENUE = "0x000000000000000000000000000000000000aa01" as Address; +const LIQUIDATOR = "0x000000000000000000000000000000000000bb01" as Address; +const TARGET_USER = "0x000000000000000000000000000000000000cc01" as Address; + +const VENUE_ABI: Abi = parseAbi([ + "function liquidate(address user) returns (uint256)", + "function liquidateOrder(address user, bytes32 orderId)", + "event Liquidated(address indexed user, address indexed liquidator, uint256 fee)", + "event PositionLiquidated(address indexed user, uint256 liquidatorFee)", + "error NotLiquidatable()", + "error OrdersStillOpen()", + "error UnknownProblem()", +]); + +function makeConfig(dryRun = false): Config { + return { + keeper: { dryRun }, + coordinator: { confirmationBlocks: 1 }, + } as Config; +} + +interface ChainStubOptions { + /** What `simulateContract` should do — return a request, throw the given error. */ + simulate: { request: { ok: true } } | { error: unknown }; + /** Hash to return from `writeContract` — required when simulate succeeds and dryRun is false. */ + writeHash?: `0x${string}`; + /** Receipt fed to `waitForTransactionReceipt`. */ + receipt?: TransactionReceipt; +} + +function makeChain(opts: ChainStubOptions): Chain & { calls: { writeCount: number } } { + const calls = { writeCount: 0 }; + const chain = { + account: { address: LIQUIDATOR } as { address: Address }, + publicClient: { + simulateContract: async () => { + if ("error" in opts.simulate) throw opts.simulate.error; + return opts.simulate; + }, + waitForTransactionReceipt: async () => opts.receipt as TransactionReceipt, + }, + walletClient: { + writeContract: async () => { + calls.writeCount++; + if (opts.writeHash === undefined) throw new Error("test bug: writeHash not provided"); + return opts.writeHash; + }, + }, + calls, + } as unknown as Chain & { calls: { writeCount: number } }; + return chain; +} + +/** + * Build a viem-compatible BaseError that wraps a ContractFunctionRevertedError + * with the given errorName. `sendLiquidate` calls `err.walk()` to find it. + */ +function makeRevert(errorName: string): BaseError { + const inner = new ContractFunctionRevertedError({ + abi: VENUE_ABI, + data: undefined, + functionName: "liquidate", + }); + // Patch `data.errorName` directly — the constructor only sets it when + // it can decode raw return data, which we don't have here. + (inner as unknown as { data: { errorName: string } }).data = { errorName }; + const outer = new BaseError("simulated revert"); + // viem's `walk()` calls `cause` recursively; injecting our inner here is + // the same shape `simulateContract` produces in real failures. + (outer as unknown as { cause: unknown }).cause = inner; + return outer; +} + +/** + * Build a real-looking receipt with N `eventName` logs whose `fee` field + * each contributes to the summed `feeEarned`. We only have to set the + * `topics` and `data` correctly for `parseEventLogs` to decode them — + * everything else viem ignores. + */ +function makeReceiptWithFees(eventName: "Liquidated" | "PositionLiquidated", fees: bigint[]): TransactionReceipt { + const logs = fees.map((fee) => { + if (eventName === "Liquidated") { + const topics = encodeEventTopics({ + abi: VENUE_ABI, + eventName: "Liquidated", + args: { user: TARGET_USER, liquidator: LIQUIDATOR }, + }); + return { + address: VENUE, + topics, + data: encodeAbiParameters([{ type: "uint256" }], [fee]), + }; + } + const topics = encodeEventTopics({ + abi: VENUE_ABI, + eventName: "PositionLiquidated", + args: { user: TARGET_USER }, + }); + return { + address: VENUE, + topics, + data: encodeAbiParameters([{ type: "uint256" }], [fee]), + }; + }); + return { + transactionHash: "0xfeed", + logs, + status: "success", + } as unknown as TransactionReceipt; +} + +describe("tx/liquidate: simulate-only path", () => { + it("returns { skipped: 'notLiquidatable' } by default when simulate reverts with a recoverable error", async () => { + const chain = makeChain({ simulate: { error: makeRevert("NotLiquidatable") } }); + const out = await sendLiquidate({ + chain, + config: makeConfig(), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + }); + assert.deepEqual(out, { skipped: "notLiquidatable" }); + assert.equal(chain.calls.writeCount, 0, "writeContract must not be called on revert"); + }); + + it("maps recoverable reverts via mapSkip when supplied", async () => { + const chain = makeChain({ simulate: { error: makeRevert("OrdersStillOpen") } }); + const out = await sendLiquidate({ + chain, + config: makeConfig(), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + mapSkip: (e) => (e === "OrdersStillOpen" ? ("ordersStillOpen" as const) : ("notLiquidatable" as const)), + }); + assert.deepEqual(out, { skipped: "ordersStillOpen" }); + }); + + it("rethrows unknown reverts (we should not silently swallow them)", async () => { + const chain = makeChain({ simulate: { error: makeRevert("UnknownProblem") } }); + await assert.rejects( + sendLiquidate({ + chain, + config: makeConfig(), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + }), + ); + }); + + it("rethrows non-BaseError failures (RPC error, network, etc.)", async () => { + const chain = makeChain({ simulate: { error: new Error("RPC down") } }); + await assert.rejects( + sendLiquidate({ + chain, + config: makeConfig(), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + }), + /RPC down/, + ); + }); +}); + +describe("tx/liquidate: dry-run path", () => { + it("logs but does NOT call writeContract when dryRun=true", async () => { + const chain = makeChain({ simulate: { request: { ok: true } } }); + const out = await sendLiquidate({ + chain, + config: makeConfig(true), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + }); + assert.deepEqual(out, { feeEarned: 0n, receipt: null }); + assert.equal(chain.calls.writeCount, 0); + }); +}); + +describe("tx/liquidate: broadcast + fee aggregation", () => { + it("sums `fee` across multiple Liquidated events in a single receipt", async () => { + const chain = makeChain({ + simulate: { request: { ok: true } }, + writeHash: "0xabcdef", + receipt: makeReceiptWithFees("Liquidated", [1_000n, 2_500n, 100n]), + }); + const out = await sendLiquidate({ + chain, + config: makeConfig(false), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "Liquidated", + }); + assert.ok("feeEarned" in out, "expected success outcome"); + if ("feeEarned" in out) { + assert.equal(out.feeEarned, 3_600n); + assert.equal(chain.calls.writeCount, 1); + } + }); + + it("falls back to `liquidatorFee` field when `fee` is absent (PositionLiquidated)", async () => { + const chain = makeChain({ + simulate: { request: { ok: true } }, + writeHash: "0xbeef01", + receipt: makeReceiptWithFees("PositionLiquidated", [42n]), + }); + const out = await sendLiquidate({ + chain, + config: makeConfig(false), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidate", + args: [TARGET_USER], + feeEventName: "PositionLiquidated", + }); + assert.ok("feeEarned" in out); + if ("feeEarned" in out) assert.equal(out.feeEarned, 42n); + }); + + it("returns feeEarned=0 when feeEventName is null (orders-only leg)", async () => { + const chain = makeChain({ + simulate: { request: { ok: true } }, + writeHash: "0xbeef02", + receipt: makeReceiptWithFees("Liquidated", [999n]), // log present but ignored + }); + const out = await sendLiquidate({ + chain, + config: makeConfig(false), + logger: silentLogger, + address: VENUE, + abi: VENUE_ABI, + functionName: "liquidateOrder", + args: [TARGET_USER, "0x" + "00".repeat(32)], + feeEventName: null, + }); + assert.ok("feeEarned" in out); + if ("feeEarned" in out) assert.equal(out.feeEarned, 0n); + }); +}); + +describe("tx/liquidate: __testing internals", () => { + it("decodeRecoverableRevert returns the errorName for known reverts", () => { + assert.equal(__testing.decodeRecoverableRevert(makeRevert("NotLiquidatable")), "NotLiquidatable"); + assert.equal(__testing.decodeRecoverableRevert(makeRevert("OrdersStillOpen")), "OrdersStillOpen"); + }); + + it("decodeRecoverableRevert returns undefined for unknown reverts", () => { + assert.equal(__testing.decodeRecoverableRevert(makeRevert("UnknownProblem")), undefined); + }); + + it("decodeRecoverableRevert returns undefined for non-Base errors (RPC failure, etc.)", () => { + assert.equal(__testing.decodeRecoverableRevert(new Error("rpc")), undefined); + assert.equal(__testing.decodeRecoverableRevert("not even an error"), undefined); + assert.equal(__testing.decodeRecoverableRevert(new AbiFunctionNotFoundError("foo")), undefined); + }); +}); diff --git a/keeper/tests/venues/futures-marketid.test.ts b/keeper/tests/venues/futures-marketid.test.ts new file mode 100644 index 0000000..4e27a69 --- /dev/null +++ b/keeper/tests/venues/futures-marketid.test.ts @@ -0,0 +1,19 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { deliveryAtMarketId, marketIdToDeliveryAt } from "../../src/venues/futures.ts"; + +describe("futures venue marketId helpers", () => { + it("encodes a delivery date as bytes32 and round-trips", () => { + const deliveryAt = 1_756_416_000n; // 2025-08-29T00:00:00Z + const id = deliveryAtMarketId(deliveryAt); + assert.equal(id.length, 66, "bytes32 hex string is 0x + 64 chars"); + assert.equal(marketIdToDeliveryAt(id), deliveryAt); + }); + + it("encodes 0 as the zero bytes32", () => { + assert.equal( + deliveryAtMarketId(0n), + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + }); +}); diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts new file mode 100644 index 0000000..f1d2862 --- /dev/null +++ b/keeper/tests/venues/futures.test.ts @@ -0,0 +1,216 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address, Hex } from "viem"; +import { FuturesVenue, deliveryAtMarketId } from "../../src/venues/futures.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; +const BUYER = "0x0000000000000000000000000000000000000b0b" as Address; +const SELLER = "0x0000000000000000000000000000000000005e11" as Address; + +interface ReadCall { + functionName: string; + args?: readonly unknown[]; +} + +interface MulticallShape { + contracts: readonly ReadCall[]; +} + +function makeChainStub(opts: { + readContract?: (call: ReadCall) => unknown; + multicall?: (calls: readonly ReadCall[]) => readonly unknown[]; +}): Chain { + return { + publicClient: { + readContract: async (call: ReadCall) => opts.readContract?.(call), + multicall: async ({ contracts }: MulticallShape) => + opts.multicall?.(contracts), + }, + } as unknown as Chain; +} + +function makeConfigStub(): Config { + return { + futures: { address: FUTURES }, + keeper: { dryRun: false }, + } as Config; +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as ConstructorParameters[2]; + +const DELIVERY_AT = 1_756_416_000n; // 2025-08-28T18:40:00Z (slice(0,10) → "2025-08-28") +const DELIVERY_DURATION_DAYS = 7n; + +/** Reusable stub: deliveryDurationDays + market price + (positionIds | orderIds) reads. */ +function makeReadHandler(deliveryDurationDays: bigint, marketPrice: bigint, listResult: readonly Hex[]) { + return (call: ReadCall): unknown => { + if (call.functionName === "deliveryDurationDays") return Number(deliveryDurationDays); + if (call.functionName === "getMarketPrice") return marketPrice; + if (call.functionName === "getOrderIds" || call.functionName === "getPositionIds") return listResult; + throw new Error(`unexpected readContract call: ${call.functionName}`); + }; +} + +describe("futures venue: marketLabel", () => { + it("renders deliveryAt as an ISO date prefix", () => { + const venue = new FuturesVenue(makeChainStub({}), makeConfigStub(), silentLogger); + const id = deliveryAtMarketId(DELIVERY_AT); + assert.equal(venue.marketLabel(id), "futures 2025-08-28"); + }); +}); + +describe("futures venue: readOpenOrders", () => { + it("returns empty when getOrderIds is empty (no extra multicall)", async () => { + let multicallCount = 0; + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + multicall: () => { + multicallCount++; + return []; + }, + }); + 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"); + }); + + it("hydrates each order's deliveryAt as its marketId", async () => { + const orderIds: Hex[] = [ + "0x000000000000000000000000000000000000000000000000000000000000000a", + "0x000000000000000000000000000000000000000000000000000000000000000b", + ]; + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, orderIds), + multicall: (calls) => { + // One getOrderById per order id, in order. + assert.equal(calls.length, 2); + for (const c of calls) assert.equal(c.functionName, "getOrderById"); + return [ + { isBuy: true, participant: BUYER, deliveryAt: DELIVERY_AT, pricePerDay: 50n }, + { isBuy: false, participant: BUYER, deliveryAt: DELIVERY_AT + 86_400n, pricePerDay: 60n }, + ]; + }, + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + const orders = await venue.readOpenOrders(BUYER); + assert.equal(orders.length, 2); + assert.equal(orders[0]?.id, orderIds[0]); + assert.equal(orders[0]?.marketId, deliveryAtMarketId(DELIVERY_AT)); + assert.equal(orders[1]?.marketId, deliveryAtMarketId(DELIVERY_AT + 86_400n)); + }); +}); + +describe("futures venue: readPositions", () => { + it("returns empty when getPositionIds is empty", async () => { + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + multicall: () => [], + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + const positions = await venue.readPositions(BUYER); + assert.equal(positions.length, 0); + }); + + it("computes long-side underwater PnL for a buyer when market drops below entry", async () => { + const positionIds: Hex[] = ["0x" + "11".repeat(32) as Hex]; + const buyPx = 100n; + const sellPx = 100n; + const marketPrice = 70n; // long → loses (100-70)*7days = 210 per contract + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + multicall: (calls) => { + assert.equal(calls.length, 1); + assert.equal(calls[0]?.functionName, "getPositionById"); + return [ + { + seller: SELLER, + buyer: BUYER, + buyPricePerDay: buyPx, + sellPricePerDay: sellPx, + deliveryAt: DELIVERY_AT, + }, + ]; + }, + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(BUYER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, (buyPx - marketPrice) * DELIVERY_DURATION_DAYS); + assert.equal(pos.notional, buyPx * DELIVERY_DURATION_DAYS); + assert.equal(pos.marketId, deliveryAtMarketId(DELIVERY_AT)); + }); + + it("computes short-side underwater PnL for a seller when market rises above entry", async () => { + const positionIds: Hex[] = ["0x" + "22".repeat(32) as Hex]; + const sellPx = 100n; + const buyPx = 100n; + const marketPrice = 130n; // short → loses (130-100)*7days = 210 per contract + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + multicall: () => [ + { + seller: SELLER, + buyer: BUYER, + buyPricePerDay: buyPx, + sellPricePerDay: sellPx, + deliveryAt: DELIVERY_AT, + }, + ], + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(SELLER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, (marketPrice - sellPx) * DELIVERY_DURATION_DAYS); + assert.equal(pos.notional, sellPx * DELIVERY_DURATION_DAYS); + }); + + it("reports zero loss when the user is in profit", async () => { + const positionIds: Hex[] = ["0x" + "33".repeat(32) as Hex]; + const chain = makeChainStub({ + readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 150n, positionIds), + multicall: () => [ + { + seller: SELLER, + buyer: BUYER, + buyPricePerDay: 100n, + sellPricePerDay: 100n, + deliveryAt: DELIVERY_AT, + }, + ], + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(BUYER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, 0n, "buyer with market > entry is in profit"); + }); + + it("caches deliveryDurationDays across calls (read once)", async () => { + let durationReads = 0; + const chain = makeChainStub({ + readContract: (call) => { + if (call.functionName === "deliveryDurationDays") { + durationReads++; + return Number(DELIVERY_DURATION_DAYS); + } + if (call.functionName === "getMarketPrice") return 100n; + if (call.functionName === "getPositionIds") return []; + throw new Error(`unexpected ${call.functionName}`); + }, + multicall: () => [], + }); + const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); + await venue.readPositions(BUYER); + await venue.readPositions(BUYER); + await venue.readPositions(BUYER); + assert.equal(durationReads, 1, "deliveryDurationDays read only once"); + }); +}); diff --git a/keeper/tests/venues/perps.test.ts b/keeper/tests/venues/perps.test.ts new file mode 100644 index 0000000..5984c0b --- /dev/null +++ b/keeper/tests/venues/perps.test.ts @@ -0,0 +1,166 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { pad, type Address } from "viem"; +import { PerpsVenue, PERPS_MARKET_ID } from "../../src/venues/perps.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const PERPS = "0x00000000000000000000000000000000000DEAd5" as Address; +const USER = "0x00000000000000000000000000000000deadbeef" as Address; + +interface ReadCall { + functionName: string; + args?: readonly unknown[]; +} + +/** + * Minimal stub: only handles the `readContract` and `multicall` shapes the + * perps venue actually uses. Each handler receives the call and returns the + * pre-canned result — keeps tests focused on the transformation logic. + */ +function makeChainStub(opts: { + readContract?: (call: ReadCall) => unknown; + multicall?: (calls: readonly ReadCall[]) => readonly unknown[]; +}): Chain { + return { + publicClient: { + readContract: async (call: ReadCall) => opts.readContract?.(call), + multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => + opts.multicall?.(contracts), + }, + } as unknown as Chain; +} + +function makeConfigStub(): Config { + return { + perps: { address: PERPS }, + keeper: { dryRun: false }, + } as Config; +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as ConstructorParameters[2]; + +describe("perps venue: marketLabel", () => { + it("always returns 'perps' regardless of marketId", () => { + const venue = new PerpsVenue(makeChainStub({}), makeConfigStub(), silentLogger); + assert.equal(venue.marketLabel(PERPS_MARKET_ID), "perps"); + // Even an unrelated marketId resolves to the single perps label. + assert.equal(venue.marketLabel(pad("0xdead", { size: 32 })), "perps"); + }); +}); + +describe("perps venue: readOpenOrders", () => { + it("returns empty when getUserOrders is empty", async () => { + const chain = makeChainStub({ + readContract: (call) => { + assert.equal(call.functionName, "getUserOrders"); + return [] as readonly `0x${string}`[]; + }, + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const orders = await venue.readOpenOrders(USER); + assert.equal(orders.length, 0); + }); + + it("tags each order id with the single PERPS_MARKET_ID sentinel", async () => { + const ids = [pad("0xa", { size: 32 }), pad("0xb", { size: 32 })]; + const chain = makeChainStub({ + readContract: () => ids, + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const orders = await venue.readOpenOrders(USER); + assert.equal(orders.length, 2); + for (const o of orders) { + assert.equal(o.marketId, PERPS_MARKET_ID); + } + assert.equal(orders[0]?.id, ids[0]); + assert.equal(orders[1]?.id, ids[1]); + }); +}); + +describe("perps venue: readPositions", () => { + // Quantities are scaled by 1e6 (QUANTITY_DECIMALS) on-chain. + const QTY_SCALE = 1_000_000n; + + it("returns no position when netQuantity is 0", async () => { + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: 0n, aggregatedEntryPrice: 50n }, + 100n, // marketPrice + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const positions = await venue.readPositions(USER); + assert.equal(positions.length, 0); + }); + + it("computes unrealizedLoss=0 and notional=marketPrice*qty for a profitable long", async () => { + const qty = 2n * QTY_SCALE; // long 2 contracts + const entryPrice = 100n; + const marketPrice = 150n; // up → long is in profit, no loss + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(USER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, 0n); + assert.equal(pos.notional, (marketPrice * 2n * QTY_SCALE) / QTY_SCALE); + }); + + it("computes unrealizedLoss correctly for an underwater long (price drop)", async () => { + const qty = 3n * QTY_SCALE; // long 3 + const entryPrice = 200n; + const marketPrice = 150n; // -50 per contract × 3 contracts = 150 loss + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(USER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, 150n); + assert.equal(pos.notional, marketPrice * 3n); + }); + + it("computes unrealizedLoss correctly for an underwater short (price rise)", async () => { + const qty = -4n * QTY_SCALE; // short 4 + const entryPrice = 100n; + const marketPrice = 130n; // +30 against the short × 4 = 120 loss + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(USER); + assert.ok(pos); + assert.equal(pos.unrealizedLoss, 120n); + assert.equal(pos.notional, marketPrice * 4n); + }); + + it("synthesises a deterministic positionId from the user address (bytes32(user))", async () => { + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, + 100n, + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(USER); + assert.ok(pos); + assert.equal(pos.id, pad(USER, { size: 32 })); + }); +}); diff --git a/keeper/tsconfig.json b/keeper/tsconfig.json new file mode 100644 index 0000000..cc28ba7 --- /dev/null +++ b/keeper/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src", "tests", "scripts"] +} diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 81e6e18..140e978 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -1,10 +1,5 @@ import type pino from "pino"; -import type { - OwnOrder, - OwnOrderEvent, - OwnOrderSource, - Unsubscribe, -} from "../../core/adapter.ts"; +import type { OwnOrder, OwnOrderEvent, OwnOrderSource, Unsubscribe } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FUTURES_INSTRUMENT_ID } from "./events.ts"; @@ -78,11 +73,15 @@ export class FuturesOwnOrders implements OwnOrderSource { functionName: "getOrderById" as const, args: [id] as const, })); - const orders = await this.venue.publicClient.multicall({ allowFailure: false, contracts: calls }); + const orders = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: calls, + }); for (let i = 0; i < orderIds.length; i++) { const o = orders[i]; - if (!o.participant || o.participant === "0x0000000000000000000000000000000000000000") continue; + if (!o.participant || o.participant === "0x0000000000000000000000000000000000000000") + continue; this.cache.set(orderIds[i], { orderId: orderIds[i], price: o.pricePerDay, From 88a1269765b67ae5d65cb77adf361cf54589ec63 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 14 May 2026 23:06:39 +0200 Subject: [PATCH 021/180] integration tests --- .../contracts/mocks/AggregatorEventMock.sol | 90 ++++ keeper/package.json | 10 +- keeper/pnpm-lock.yaml | 30 +- keeper/scripts/compile-siblings.ts | 68 +++ keeper/tests/integration/artifacts.ts | 133 +++++ keeper/tests/integration/buildKeeper.ts | 231 +++++++++ keeper/tests/integration/deployStack.ts | 376 ++++++++++++++ keeper/tests/integration/helpers.ts | 242 +++++++++ .../integration/keeper.integration.test.ts | 330 ++++++++++++ keeper/tests/integration/loadFixture.ts | 81 +++ keeper/tests/integration/nodeProcess.ts | 149 ++++++ keeper/tests/integration/scenarios.ts | 477 ++++++++++++++++++ keeper/tests/integration/webhookSink.ts | 64 +++ 13 files changed, 2253 insertions(+), 28 deletions(-) create mode 100644 contracts/contracts/mocks/AggregatorEventMock.sol create mode 100644 keeper/scripts/compile-siblings.ts create mode 100644 keeper/tests/integration/artifacts.ts create mode 100644 keeper/tests/integration/buildKeeper.ts create mode 100644 keeper/tests/integration/deployStack.ts create mode 100644 keeper/tests/integration/helpers.ts create mode 100644 keeper/tests/integration/keeper.integration.test.ts create mode 100644 keeper/tests/integration/loadFixture.ts create mode 100644 keeper/tests/integration/nodeProcess.ts create mode 100644 keeper/tests/integration/scenarios.ts create mode 100644 keeper/tests/integration/webhookSink.ts diff --git a/contracts/contracts/mocks/AggregatorEventMock.sol b/contracts/contracts/mocks/AggregatorEventMock.sol new file mode 100644 index 0000000..4c1b052 --- /dev/null +++ b/contracts/contracts/mocks/AggregatorEventMock.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title AggregatorEventMock +/// @notice Minimal Chainlink `AggregatorV3Interface` implementation that +/// emits `AnswerUpdated` whenever the price is set. +/// +/// The perps `PriceOracleMock` already implements `latestRoundData` +/// /`decimals` / `setPrice`, but it does **not** emit +/// `AnswerUpdated` — its callers only ever poll. The keeper's +/// predictive layer, however, subscribes to `AnswerUpdated` on a +/// BTC/USDC feed as its hot-path trigger, so for the integration +/// test we need a feed that actually fires the event. +/// +/// Field layout mirrors a real Chainlink `AggregatorProxy`: +/// - 80-bit roundId monotonically increments on every `setPrice` +/// - `updatedAt` / `startedAt` are the block timestamp of the set +/// - `answeredInRound == roundId` (no out-of-order rounds in tests) +contract AggregatorEventMock { + /// @dev Same indexing order Chainlink uses — `current` and `roundId` + /// are both indexed so subgraph / off-chain consumers can filter + /// on either. + event AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt); + + int256 private _answer; + uint80 private _roundId; + uint256 private _updatedAt; + uint8 private immutable _decimals; + string private _description; + + constructor(int256 initialAnswer, uint8 decimals_, string memory description_) { + _answer = initialAnswer; + _decimals = decimals_; + _description = description_; + _roundId = 1; + _updatedAt = block.timestamp; + } + + function decimals() external view returns (uint8) { + return _decimals; + } + + function description() external view returns (string memory) { + return _description; + } + + function version() external pure returns (uint256) { + return 4; + } + + /// @notice Push a new answer and emit `AnswerUpdated`. Used by the + /// integration test to trigger predictor evaluation. + function setAnswer(int256 newAnswer) external { + _answer = newAnswer; + _roundId += 1; + _updatedAt = block.timestamp; + emit AnswerUpdated(newAnswer, _roundId, _updatedAt); + } + + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return (_roundId, _answer, _updatedAt, _updatedAt, _roundId); + } + + function getRoundData(uint80) + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + // No historical round storage — `getRoundData(0)` returns the same + // value as `latestRoundData()`. The integration test only ever + // polls `latestRoundData` after an event tick, so this is fine. + return (_roundId, _answer, _updatedAt, _updatedAt, _roundId); + } +} diff --git a/keeper/package.json b/keeper/package.json index 013794b..7fe4bc7 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -12,16 +12,18 @@ "dev": "pnpm node --env-file=.env src/index.ts | pino-pretty", "dev:dry": "DRY_RUN=true pnpm node --env-file=.env src/index.ts | pino-pretty", "start": "pnpm node --env-file=.env src/index.ts", - "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/*.test.ts' 'tests/alert/**/*.test.ts' 'tests/coordinator/**/*.test.ts' 'tests/discovery/**/*.test.ts' 'tests/oracle/**/*.test.ts' 'tests/pme/**/*.test.ts' 'tests/predict/**/*.test.ts' 'tests/runtime/**/*.test.ts' 'tests/tx/**/*.test.ts' 'tests/venues/**/*.test.ts'", "test:watch": "pnpm node --test --watch --test-concurrency=1 'tests/**/*.test.ts'", + "pretest:integration": "pnpm node ./scripts/compile-siblings.ts", + "test:integration": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/integration/**/*.test.ts'", "typecheck": "tsc --noEmit", "docker": "docker build -t collateral-margin-keeper ." }, "dependencies": { "amaro": "^1.1.9", - "collateral-margin": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", - "derivatives-marketplace": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", - "futures-marketplace": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", + "collateral-margin": "link:../contracts/abi", + "derivatives-marketplace": "link:../../perps/contracts/abi", + "futures-marketplace": "link:../../futures-marketplace/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index cc9c610..b3a40a9 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^1.1.9 version: 1.1.9 collateral-margin: - specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi + specifier: link:../contracts/abi + version: link:../contracts/abi derivatives-marketplace: - specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi + specifier: link:../../perps/contracts/abi + version: link:../../perps/contracts/abi futures-marketplace: - specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi + specifier: link:../../futures-marketplace/contracts/abi + version: link:../../futures-marketplace/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -135,20 +135,12 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79} - version: 0.0.0 - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - derivatives-marketplace@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8} - version: 0.0.0 - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -161,10 +153,6 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - futures-marketplace@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5} - version: 0.0.0 - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -341,14 +329,10 @@ snapshots: atomic-sleep@1.0.0: {} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79#path:/contracts/abi: {} - colorette@2.0.20: {} dateformat@4.6.3: {} - derivatives-marketplace@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f91f8848afb61268a34c9a9db7f41379584795d8#path:/contracts/abi: {} - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -359,8 +343,6 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/7cd271cc228a9c990111053316c201e1cea810f5#path:/contracts/abi: {} - help-me@5.0.0: {} isows@1.0.7(ws@8.18.3): diff --git a/keeper/scripts/compile-siblings.ts b/keeper/scripts/compile-siblings.ts new file mode 100644 index 0000000..266c446 --- /dev/null +++ b/keeper/scripts/compile-siblings.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * `pretest:integration` hook. + * + * Runs `pnpm hardhat compile` in each sibling repo whose Solidity sources + * the keeper integration test needs to deploy: + * + * - collateral-margin/contracts (this repo, hosts the test fixtures) + * - perps/contracts (HashPowerPerpsDEX) + * - futures-marketplace/contracts (Futures) + * + * Each sibling has its own Solidity dep graph (OZ, OZ-upgradeable, + * chainlink, solidity-linked-list, `hardhat/console.sol`, the + * `collateral-margin` workspace dep that futures pulls in). Trying to + * compile those .sol files from the keeper would mean replicating each + * sibling's full dep tree here. Instead we shell out to each sibling's + * existing Hardhat setup — they already know how to resolve their own + * imports — and the keeper just reads the resulting artifact JSON. + * + * Path resolution mirrors `tests/integration/artifacts.ts`: + * PERPS_REPO – defaults to ../../perps + * FUTURES_REPO – defaults to ../../futures-marketplace + * + * Compilation is skipped when `SKIP_COMPILE_SIBLINGS=1` (used in CI when + * the artifacts have already been built upstream and committed). + */ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.env.SKIP_COMPILE_SIBLINGS === "1") { + console.log("[compile-siblings] SKIP_COMPILE_SIBLINGS=1, skipping"); + process.exit(0); +} + +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(here, "..", "..", ".."); + +const targets = [ + { name: "collateral-margin", dir: resolve(here, "..", "..", "contracts") }, + { name: "perps", dir: process.env.PERPS_REPO ?? resolve(workspaceRoot, "perps", "contracts") }, + { + name: "futures-marketplace", + dir: process.env.FUTURES_REPO ?? resolve(workspaceRoot, "futures-marketplace", "contracts"), + }, +]; + +for (const target of targets) { + if (!existsSync(target.dir)) { + console.error( + `[compile-siblings] ${target.name} not found at ${target.dir}.\n` + + `Override the path via PERPS_REPO / FUTURES_REPO if your checkout layout differs.`, + ); + process.exit(1); + } + + console.log(`[compile-siblings] ${target.name}: pnpm hardhat compile (${target.dir})`); + const result = spawnSync("pnpm", ["hardhat", "compile"], { + cwd: target.dir, + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + console.error(`[compile-siblings] ${target.name} compile failed with status ${result.status}`); + process.exit(result.status ?? 1); + } +} diff --git a/keeper/tests/integration/artifacts.ts b/keeper/tests/integration/artifacts.ts new file mode 100644 index 0000000..1266b19 --- /dev/null +++ b/keeper/tests/integration/artifacts.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Abi, Hex } from "viem"; + +/** + * Filesystem-path-based artifact loader. + * + * The keeper integration test runs against the *real* compiled bytecode of + * the perps and futures contracts. Those contracts live in sibling repos + * with their own Solidity dep trees (OZ, OZ upgradeable, chainlink, + * solidity-linked-list, `hardhat/console.sol`, `collateral-margin`) and + * compile cleanly only inside those repos' own Hardhat setups. + * + * `pretest:integration` therefore runs each sibling's `pnpm hardhat compile` + * before the test runs, and this module just reads the resulting Hardhat + * artifact JSON via filesystem paths. No npm gymnastics. + * + * Paths are env-overridable so CI / other devs can point at non-default + * checkout locations: + * + * PERPS_REPO – absolute path to the perps repo root + * FUTURES_REPO – absolute path to the futures-marketplace repo root + * + * Defaults assume the standard `~/Dev/titan/{perps,futures-marketplace,collateral-margin}` + * layout that the team uses locally. + */ + +export interface CompiledArtifact { + abi: Abi; + bytecode: Hex; +} + +const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../.."); +const DEFAULT_PERPS = resolve(WORKSPACE_ROOT, "perps"); +const DEFAULT_FUTURES = resolve(WORKSPACE_ROOT, "futures-marketplace"); +const SELF_ROOT = resolve(import.meta.dirname, "../../.."); + +/** + * All entries point at the *repo root* (one level above the `contracts/` + * package directory). The `readArtifact` path join then unconditionally + * tacks on `contracts/artifacts/...`, so every entry follows the same + * convention regardless of where the repo is checked out. + */ +const REPO_PATHS = { + perps: process.env.PERPS_REPO ?? DEFAULT_PERPS, + futures: process.env.FUTURES_REPO ?? DEFAULT_FUTURES, + collateral: SELF_ROOT, +} as const; + +type Repo = keyof typeof REPO_PATHS; + +/** + * Read a Hardhat artifact JSON and return just the `(abi, bytecode)` pair + * the deploy module cares about. Throws a clear error if the path is + * missing — typically means `pretest:integration` didn't run, or the + * sibling repo hasn't been compiled yet. + */ +function readArtifact(repo: Repo, contractPath: string, contractName: string): CompiledArtifact { + // Hardhat's artifact layout: + // /contracts/artifacts/.sol/.json + // `contractPath` is the path *under* `contracts/` (e.g. `contracts/Foo`), + // but for npm-resolved sources it lives under `@openzeppelin/contracts/…` + // — the dir tree mirrors the import path verbatim. + const repoRoot = REPO_PATHS[repo]; + const artifactPath = resolve( + repoRoot, + "contracts/artifacts", + `${contractPath}.sol`, + `${contractName}.json`, + ); + let raw: string; + try { + raw = readFileSync(artifactPath, "utf-8"); + } catch (err) { + const cause = err instanceof Error ? err.message : String(err); + throw new Error( + `Missing Hardhat artifact at ${artifactPath}.\n` + + `Run \`pretest:integration\` (or compile the sibling repo manually) before running the tests.\n` + + `Underlying error: ${cause}`, + ); + } + const parsed = JSON.parse(raw) as { abi: Abi; bytecode: Hex }; + if (parsed.bytecode === undefined || parsed.bytecode === "0x") { + throw new Error( + `Artifact at ${artifactPath} has no bytecode — is it an interface? (loader expected a deployable contract).`, + ); + } + return { abi: parsed.abi, bytecode: parsed.bytecode }; +} + +/** + * Concrete artifact handles, declared once so deploy code can typo-check + * against them rather than passing magic strings around. + */ +export const artifacts = { + // ── collateral-margin (local) ───────────────────────────────────────── + vault: () => readArtifact("collateral", "contracts/CollateralVault", "CollateralVault"), + pme: () => readArtifact("collateral", "contracts/PortfolioMarginEngine", "PortfolioMarginEngine"), + usdc: () => readArtifact("collateral", "contracts/mocks/USDCMock", "USDCMock"), + erc1967Proxy: () => readArtifact( + "collateral", + "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy", + "ERC1967Proxy", + ), + /** + * Aggregator-shape oracle that emits `AnswerUpdated` on setPrice. Added + * to this repo's mocks because the perps `PriceOracleMock` is event-less + * (it satisfies the venue's `latestRoundData` read path but not the + * predictor's BTC/USDC event subscription). + */ + aggregatorEventMock: () => readArtifact( + "collateral", + "contracts/mocks/AggregatorEventMock", + "AggregatorEventMock", + ), + + // ── perps (sibling repo) ────────────────────────────────────────────── + perps: () => readArtifact("perps", "contracts/HashPowerPerpsDEX", "HashPowerPerpsDEX"), + priceOracleMock: () => readArtifact("perps", "contracts/mocks/PriceOracleMock", "PriceOracleMock"), + /** + * Multicall3 (shipped by perps for the indexer/keeper). We deploy this on + * the test node so viem's `multicall` action — which `pme/health.ts` uses + * for batched reads — has a contract to dispatch through. viem refuses to + * multicall against a chain whose `contracts.multicall3.address` is unset. + */ + multicall3: () => readArtifact("perps", "contracts/Multicall3", "Multicall3"), + + // ── futures (sibling repo) ──────────────────────────────────────────── + futures: () => readArtifact("futures", "contracts/Futures", "Futures"), +} as const; + +/** Resolved repo paths — exported for diagnostic logs. */ +export const repoRoots = REPO_PATHS; diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts new file mode 100644 index 0000000..1ac144c --- /dev/null +++ b/keeper/tests/integration/buildKeeper.ts @@ -0,0 +1,231 @@ +import pino from "pino"; +import { + createPublicClient, + createWalletClient, + http, + type Account, + type PublicClient, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { hardhat } from "viem/chains"; +import type { Config } from "../../src/config.ts"; +import type { Chain } from "../../src/chain.ts"; +import { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import { CoordinatorQueue } from "../../src/coordinator/queue.ts"; +import { Planner } from "../../src/coordinator/planner.ts"; +import { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; +import { Scheduler } from "../../src/runtime/scheduler.ts"; +import { Notifier } from "../../src/alert/notifier.ts"; +import { PerpsVenue } from "../../src/venues/perps.ts"; +import { FuturesVenue } from "../../src/venues/futures.ts"; +import { PriceFeed } from "../../src/oracle/priceFeed.ts"; +import { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; +import type { Venue } from "../../src/venues/types.ts"; +import type { DeployedStack } from "./deployStack.ts"; +import { HARDHAT_PRIVATE_KEYS } from "./deployStack.ts"; + +/** + * Wires the keeper component graph against an already-deployed stack. + * Mirrors the order in `keeper/src/index.ts::main` but skips bits the test + * doesn't need (Healthcheck HTTP server, WebhookIngester, SIGINT handlers). + * + * Every component sees the SAME `Chain` instance; the `publicClient` is + * configured with a 100ms polling interval so `watchContractEvent` reacts + * fast enough that `waitFor` loops don't time out (the keeper's default is + * viem's 4s, which would dominate every test). + * + * Returns a small lifecycle facade. Callers should always `await kp.stop()` + * in their `afterEach` — leaked `watchContractEvent` unwatchers fire after + * `evm_revert` and tend to crash the next test with stale state. + */ +export interface KeeperHarness { + config: Config; + chain: Chain; + tracker: ParticipantTracker; + queue: CoordinatorQueue; + planner: Planner; + executor: CoordinatorExecutor; + scheduler: Scheduler; + notifier: Notifier; + priceFeed: PriceFeed; + predictor: PredictiveCoordinator; + start(): Promise; + stop(): Promise; +} + +export interface BuildKeeperOverrides { + webhookUrl?: string; + /** Default 60_000 — set lower to exercise the periodic sweep mid-test. */ + sweepIntervalMs?: number; + /** Default "warn"; set "debug" when diagnosing a failing test. */ + logLevel?: pino.Level; + /** Inject your own keeper signer key. Defaults to Hardhat account #3. */ + liquidatorPrivateKey?: `0x${string}`; +} + +const LIQUIDATOR_PK = HARDHAT_PRIVATE_KEYS[3]; + +export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverrides = {}): KeeperHarness { + const config = buildConfig(stack, overrides); + const chain = buildChain( + stack.rpcUrl, + overrides.liquidatorPrivateKey ?? LIQUIDATOR_PK, + stack.addresses.multicall3, + ); + + const logger = pino({ + level: overrides.logLevel ?? "warn", + // The default JSON output makes test failures unreadable when tests + // are timing-sensitive. `transport: pino-pretty` would be ideal but + // requires the worker thread bootstrap — fine for dev but flaky in CI. + // We just turn off the noisy hostname/pid/time fields instead. + base: undefined, + timestamp: false, + }); + + const venues: Venue[] = [ + new PerpsVenue(chain, config, logger), + new FuturesVenue(chain, config, logger), + ]; + + const notifier = new Notifier(config, logger); + const tracker = new ParticipantTracker(chain, config, logger); + const queue = new CoordinatorQueue(); + const planner = new Planner(chain, config, venues, logger); + const executor = new CoordinatorExecutor(config, queue, planner, logger); + const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, logger); + // tokenDecimals flows from the deploy stack so the PriceFeed rescales + // the BTC/USDC answer to the same units used by the venue contracts. + const priceFeed = new PriceFeed(chain, config, logger, stack.config.tokenDecimals); + const predictor = new PredictiveCoordinator( + chain, + config, + tracker, + queue, + executor, + priceFeed, + logger, + notifier, + ); + + // Newly-tracked users wake idle workers — same edge `keeper/src/index.ts` + // wires in production. + tracker.onAdded(() => executor.kick()); + + let started = false; + return { + config, + chain, + tracker, + queue, + planner, + executor, + scheduler, + notifier, + priceFeed, + predictor, + async start() { + if (started) return; + started = true; + await priceFeed.start(); + await predictor.start(); + await tracker.start(); + await executor.start(); + // Scheduler is NOT started: tests drive it manually via + // `scheduler.runSweep()` to avoid timer races against `evm_revert`. + }, + async stop() { + if (!started) return; + started = false; + scheduler.stop(); + predictor.stop(); + priceFeed.stop(); + await executor.stop(); + tracker.stop(); + }, + }; +} + +function buildConfig(stack: DeployedStack, overrides: BuildKeeperOverrides): Config { + return { + chain: { + network: "hardhat", + rpcUrl: stack.rpcUrl, + discoveryMode: "events", + backfillChunkSize: 10_000n, + }, + vault: { address: stack.addresses.vault }, + perps: { address: stack.addresses.perps }, + futures: { address: stack.addresses.futures }, + pme: { address: stack.addresses.pme }, + oracle: { + hashpriceUsdcAddress: stack.addresses.hashpriceOracle, + btcUsdcFeedAddress: stack.addresses.btcUsdcFeed, + priceMoveTriggerBps: 0, // process every event for deterministic tests + }, + keeper: { + privateKey: overrides.liquidatorPrivateKey ?? LIQUIDATOR_PK, + dryRun: false, + minProfitMargin: 0n, + }, + alerts: { + webhookUrl: overrides.webhookUrl, + dedupeMs: 0, // disable dedupe for tests — every alert fires + imWarnUtilization: 0.8, + imCriticalUtilization: 0.95, + }, + triggers: { + webhookPort: 0, + }, + coordinator: { + maxConcurrentAccounts: 1, + confirmationBlocks: 0, + }, + runtime: { + sweepIntervalMs: overrides.sweepIntervalMs ?? 60_000, + healthPort: 0, + logLevel: overrides.logLevel ?? "warn", + }, + }; +} + +/** + * Test-local equivalent of `keeper/src/chain.ts::createChain`. Two + * meaningful differences from the production wiring: + * + * 1. `pollingInterval: 100` — `watchContractEvent` (tracker, priceFeed, + * predictor) sees new logs in ~one polling tick rather than the + * default 4s. Without this, every event-based test would idle for + * seconds before the keeper noticed anything happened. + * 2. `contracts.multicall3.address` set to whatever address `deployStack` + * installed Multicall3 at. viem's `multicall` action refuses to run + * against a chain whose `multicall3` is unconfigured — `pme/health.ts` + * uses it for batched reads, so it's a hard requirement. + */ +function buildChain( + rpcUrl: string, + privateKey: `0x${string}`, + multicall3Address: `0x${string}`, +): Chain { + const transport = http(rpcUrl); + const account: Account = privateKeyToAccount(privateKey); + const chainWithMulticall = { + ...hardhat, + contracts: { + ...hardhat.contracts, + multicall3: { address: multicall3Address }, + }, + }; + const publicClient: PublicClient = createPublicClient({ + chain: chainWithMulticall, + transport, + pollingInterval: 100, + }); + const walletClient: WalletClient = createWalletClient({ + chain: chainWithMulticall, + transport, + account, + }); + return { publicClient, walletClient, account }; +} diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts new file mode 100644 index 0000000..7608306 --- /dev/null +++ b/keeper/tests/integration/deployStack.ts @@ -0,0 +1,376 @@ +import { + createPublicClient, + createWalletClient, + createTestClient, + encodeFunctionData, + http, + parseUnits, + publicActions, + walletActions, + type Abi, + type Account, + type Address, + type Hex, + type PublicClient, + type TestClient, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { hardhat } from "viem/chains"; +import { artifacts, type CompiledArtifact } from "./artifacts.ts"; + +/** + * Programmatic deployment of the full collateral-margin stack against a + * running Hardhat node. Mirrors the wiring done by + * `perps/contracts/tests/fixtures.ts::deployPerpsFixture` and + * `futures-marketplace/contracts/tests/fixtures.ts::deployOnlyFuturesFixture`, + * but goes through raw viem `deployContract({ abi, bytecode, args })` rather + * than Hardhat's named-artifact resolver — that way the test can run from + * the keeper package without needing its own Hardhat config. + * + * Deterministic Hardhat private keys (well-known across the team's + * tooling) are baked in so test scenarios can sign with the SAME keys the + * deploy script uses. Account #3 is reserved for the keeper liquidator, + * matching `e2e/setup/keeper.ts` in the perps repo. + */ +export const HARDHAT_PRIVATE_KEYS = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", // #0 owner + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", // #1 alice + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", // #2 bob + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", // #3 liquidator + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", // #4 validator +] as const satisfies readonly Hex[]; + +export interface Wallet { + account: Account; + client: WalletClient; +} + +export interface DeployedStack { + publicClient: PublicClient; + testClient: TestClient; + rpcUrl: string; + accounts: { + owner: Wallet; + alice: Wallet; + bob: Wallet; + liquidator: Wallet; + validator: Wallet; + }; + addresses: { + usdc: Address; + hashpriceOracle: Address; + btcUsdcFeed: Address; + vault: Address; + pme: Address; + perps: Address; + futures: Address; + /** + * Deployed Multicall3 — fed into the keeper's chain config so viem's + * `multicall` action (used by `pme/health.ts`) has somewhere to dispatch + * batched reads. In production this would be the canonical + * `0xcA11bde05977b3631167028862bE2a173976CA11` address; on a fresh + * Hardhat node we deploy it ourselves and pin the dynamic address. + */ + multicall3: Address; + }; + abis: { + usdc: Abi; + hashpriceOracle: Abi; + btcUsdcFeed: Abi; + vault: Abi; + pme: Abi; + perps: Abi; + futures: Abi; + }; + /** Configuration values used during deploy — handy for scenarios. */ + config: { + tokenDecimals: number; + oracleDecimals: number; + initialHashprice: bigint; + initialBtcUsdc: bigint; + minimumPriceIncrement: bigint; + quantityDecimals: number; + perpsLiquidationFee: bigint; + perpsTakerFeeBps: bigint; + perpsMakerFeeBps: bigint; + futuresOrderFee: bigint; + futuresLiquidationFee: bigint; + futuresDeliveryDurationDays: number; + futuresFirstDeliveryDate: bigint; + insuranceFund: bigint; + initialUserBalance: bigint; + }; +} + +const TOKEN_DECIMALS = 6; +const ORACLE_DECIMALS = 6; +const QUANTITY_DECIMALS = 6; + +/** Hashprice = $4.21 / 100 TH/s / day (recent Braiins index), 6 decimals. */ +const INITIAL_HASHPRICE = parseUnits("4.21", ORACLE_DECIMALS); +/** Reference BTC/USDC mid-price; only the *delta* matters for predictor tests. */ +const INITIAL_BTC_USDC = parseUnits("65000", ORACLE_DECIMALS); + +const MIN_PRICE_INCREMENT = parseUnits("0.01", TOKEN_DECIMALS); +const PERPS_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); +const PERPS_TAKER_FEE_BPS = 5n; +const PERPS_MAKER_FEE_BPS = 0n; +const FUTURES_ORDER_FEE = parseUnits("1", TOKEN_DECIMALS); +const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); +const FUTURES_LIQUIDATION_MARGIN_PCT = 20; +const FUTURES_DELIVERY_DURATION_DAYS = 7; +const FUTURES_DELIVERY_INTERVAL_DAYS = 7; +const FUTURES_FUTURE_DELIVERY_DATES_COUNT = 10; +const FUTURES_SPEED_HPS = parseUnits("100", 12); +const INSURANCE_FUND = parseUnits("100000", TOKEN_DECIMALS); +const INITIAL_USER_BALANCE = parseUnits("10000", TOKEN_DECIMALS); + +const APPROVE_MAX = (1n << 256n) - 1n; + +/** + * Deploys USDC + oracles + vault + PME + perps + futures, wires every + * authorization, sets per-venue fees, funds test accounts. The returned + * stack is the canonical baseline; scenario fixtures stack additional + * actions (deposits, orders, positions) on top via `loadFixture`. + */ +export async function deployStack(rpcUrl: string): Promise { + const transport = http(rpcUrl, { timeout: 30_000 }); + const publicClient = createPublicClient({ chain: hardhat, transport }); + const testClient = createTestClient({ chain: hardhat, mode: "hardhat", transport }) + .extend(publicActions) + .extend(walletActions); + + const wallets = HARDHAT_PRIVATE_KEYS.map((pk) => { + const account = privateKeyToAccount(pk); + return { + account, + client: createWalletClient({ account, chain: hardhat, transport }), + } satisfies Wallet; + }); + // Destructure with non-null assertions — the array literal above guarantees + // 5 elements, but TS can't see that through `Array.prototype.map`. + const owner = wallets[0]!; + const alice = wallets[1]!; + const bob = wallets[2]!; + const liquidator = wallets[3]!; + const validator = wallets[4]!; + + // ── Infrastructure: Multicall3 ──────────────────────────────────────── + // Deployed first because `buildKeeper` reads its address into the chain + // config — keeper components must see it before they make any read call. + const multicall3 = await deploy(publicClient, owner.client, artifacts.multicall3(), []); + + // ── Tokens & oracles ────────────────────────────────────────────────── + const usdcArt = artifacts.usdc(); + const aggArt = artifacts.aggregatorEventMock(); + const usdc = await deploy(publicClient, owner.client, usdcArt, []); + const hashpriceOracle = await deploy(publicClient, owner.client, aggArt, [ + INITIAL_HASHPRICE, + ORACLE_DECIMALS, + "HashpriceUSDC mock", + ]); + const btcUsdcFeed = await deploy(publicClient, owner.client, aggArt, [ + INITIAL_BTC_USDC, + ORACLE_DECIMALS, + "BTC/USDC mock", + ]); + + // ── Vault (UUPS proxy) ──────────────────────────────────────────────── + const vaultArt = artifacts.vault(); + const vaultImpl = await deploy(publicClient, owner.client, vaultArt, []); + const vault = await deployProxy(publicClient, owner.client, vaultImpl, vaultArt.abi, "initialize", [ + usdc, + ]); + + // ── Perps (UUPS proxy) ──────────────────────────────────────────────── + const perpsArt = artifacts.perps(); + const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [MIN_PRICE_INCREMENT]); + const perps = await deployProxy(publicClient, owner.client, perpsImpl, perpsArt.abi, "initialize", [ + hashpriceOracle, + vault, + ]); + + // ── Futures (UUPS proxy, takes vault in constructor) ────────────────── + const futuresArt = artifacts.futures(); + const futuresImpl = await deploy(publicClient, owner.client, futuresArt, [vault]); + const latestBlock = await publicClient.getBlock(); + const firstDeliveryDate = + latestBlock.timestamp + BigInt(FUTURES_DELIVERY_DURATION_DAYS * 24 * 3600); + const futures = await deployProxy( + publicClient, + owner.client, + futuresImpl, + futuresArt.abi, + "initialize", + [ + hashpriceOracle, + validator.account.address, + FUTURES_LIQUIDATION_MARGIN_PCT, + FUTURES_SPEED_HPS, + MIN_PRICE_INCREMENT, + FUTURES_DELIVERY_DURATION_DAYS, + FUTURES_DELIVERY_INTERVAL_DAYS, + FUTURES_FUTURE_DELIVERY_DATES_COUNT, + firstDeliveryDate, + ], + ); + + // ── PME (UUPS proxy) ────────────────────────────────────────────────── + const pmeArt = artifacts.pme(); + const pmeImpl = await deploy(publicClient, owner.client, pmeArt, []); + const pme = await deployProxy(publicClient, owner.client, pmeImpl, pmeArt.abi, "initialize", [ + vault, + ]); + + // ── Wire PME ↔ venues ↔ vault ───────────────────────────────────────── + // PME -> learn about each venue so portfolio MM math includes both legs. + await write(publicClient, owner.client, pme, pmeArt.abi, "setPerps", [perps]); + await write(publicClient, owner.client, pme, pmeArt.abi, "setFutures", [futures]); + + // Vault -> point at the single margin engine + authorize each venue. + await write(publicClient, owner.client, vault, vaultArt.abi, "setMarginEngine", [pme]); + await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [perps, true]); + await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [ + futures, + true, + ]); + + // Perps -> PME + fee config. + await write(publicClient, owner.client, perps, perpsArt.abi, "setPortfolioMargin", [pme]); + await write(publicClient, owner.client, perps, perpsArt.abi, "setMatchFee", [ + Number(PERPS_TAKER_FEE_BPS), + Number(PERPS_MAKER_FEE_BPS), + ]); + await write(publicClient, owner.client, perps, perpsArt.abi, "setLiquidationFee", [ + PERPS_LIQUIDATION_FEE, + ]); + + // Futures -> PME + fees + validator URL. + await write(publicClient, owner.client, futures, futuresArt.abi, "setMarginEngine", [pme]); + await write(publicClient, owner.client, futures, futuresArt.abi, "setOrderFee", [ + FUTURES_ORDER_FEE, + ]); + await write(publicClient, owner.client, futures, futuresArt.abi, "setLiquidationFee", [ + FUTURES_LIQUIDATION_FEE, + ]); + await write(publicClient, owner.client, futures, futuresArt.abi, "setValidatorURL", [ + "//keeper-test-validator", + ]); + + // ── Fund & approve test wallets ─────────────────────────────────────── + for (const w of [alice, bob, liquidator, validator]) { + await write(publicClient, owner.client, usdc, usdcArt.abi, "transfer", [ + w.account.address, + INITIAL_USER_BALANCE, + ]); + } + for (const w of [owner, alice, bob, liquidator, validator]) { + await write(publicClient, w.client, usdc, usdcArt.abi, "approve", [vault, APPROVE_MAX]); + } + + // ── Seed insurance fund (owner-funded) ──────────────────────────────── + await write(publicClient, owner.client, vault, vaultArt.abi, "depositInsuranceFund", [ + INSURANCE_FUND, + ]); + + return { + publicClient, + testClient, + rpcUrl, + accounts: { owner, alice, bob, liquidator, validator }, + addresses: { + usdc, + hashpriceOracle, + btcUsdcFeed, + vault, + pme, + perps, + futures, + multicall3, + }, + abis: { + usdc: usdcArt.abi, + hashpriceOracle: aggArt.abi, + btcUsdcFeed: aggArt.abi, + vault: vaultArt.abi, + pme: pmeArt.abi, + perps: perpsArt.abi, + futures: futuresArt.abi, + }, + config: { + tokenDecimals: TOKEN_DECIMALS, + oracleDecimals: ORACLE_DECIMALS, + initialHashprice: INITIAL_HASHPRICE, + initialBtcUsdc: INITIAL_BTC_USDC, + minimumPriceIncrement: MIN_PRICE_INCREMENT, + quantityDecimals: QUANTITY_DECIMALS, + perpsLiquidationFee: PERPS_LIQUIDATION_FEE, + perpsTakerFeeBps: PERPS_TAKER_FEE_BPS, + perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, + futuresOrderFee: FUTURES_ORDER_FEE, + futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, + futuresDeliveryDurationDays: FUTURES_DELIVERY_DURATION_DAYS, + futuresFirstDeliveryDate: firstDeliveryDate, + insuranceFund: INSURANCE_FUND, + initialUserBalance: INITIAL_USER_BALANCE, + }, + }; +} + +// ── Internal helpers ──────────────────────────────────────────────────── + +async function deploy( + pc: PublicClient, + wc: WalletClient, + artifact: CompiledArtifact, + args: readonly unknown[], +): Promise
{ + const hash = await wc.deployContract({ + abi: artifact.abi, + bytecode: artifact.bytecode, + args: args as never, + account: wc.account!, + chain: hardhat, + }); + const receipt = await pc.waitForTransactionReceipt({ hash }); + if (receipt.contractAddress === null) { + throw new Error("deployContract receipt missing contractAddress"); + } + return receipt.contractAddress; +} + +async function deployProxy( + pc: PublicClient, + wc: WalletClient, + implementation: Address, + implAbi: Abi, + initFn: string, + initArgs: readonly unknown[], +): Promise
{ + const initData = encodeFunctionData({ + abi: implAbi, + functionName: initFn, + args: initArgs as never, + }); + return deploy(pc, wc, artifacts.erc1967Proxy(), [implementation, initData]); +} + +async function write( + pc: PublicClient, + wc: WalletClient, + address: Address, + abi: Abi, + functionName: string, + args: readonly unknown[], +): Promise { + const hash = await wc.writeContract({ + address, + abi, + functionName, + args: args as never, + account: wc.account!, + chain: hardhat, + }); + await pc.waitForTransactionReceipt({ hash }); +} diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts new file mode 100644 index 0000000..62c4f91 --- /dev/null +++ b/keeper/tests/integration/helpers.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +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"; + +/** + * Integration-test helpers. + * + * The goal of this module is to keep test bodies short and read like a + * spec: a scenario is loaded as a fixture, the test asks "what did the + * keeper do?", and helpers translate that into rich assertions with + * BigInt-safe failure messages. + * + * Three layers, in order of how often they're called from a test: + * 1. Action helpers (`runOneSweep`, `discoverUser`) — drive the keeper. + * 2. Observation helpers (`readPerpsPosition`, `readFuturesPositions`, + * `expectPerpsClosed`, ...) — query final on-chain state. + * 3. Outcome assertions (`expectLiquidated`, `expectHealthy`, ...) — type- + * narrow `PlanOutcome` and surface its fields when an assert fails. + */ + +// ───────────────────────────────────────────────────────────────────────── +// Action helpers +// ───────────────────────────────────────────────────────────────────────── + +/** + * `ParticipantTracker` is event-driven — there's a small RPC-poll delay + * between a user's first on-chain action and the keeper "knowing" about + * them. Every test that wants the keeper to act on `user` must call this + * first, otherwise `runSweep` / `planner.run` will short-circuit on an + * empty user set. + */ +export async function discoverUser( + keeper: KeeperHarness, + user: Address, + timeoutMs = 10_000, +): Promise { + await waitFor(() => keeper.tracker.has(user), timeoutMs); +} + +/** + * `discoverUser` + flush any in-flight `PredictiveCoordinator` rebuilds. + * Use this in tests that need the predictor's *initial* (pre-crash) + * thresholds to be indexed before the price moves — otherwise the + * predictor would build its first snapshot using an already-underwater + * account, and `solveLiquidationThresholds` would short-circuit. + */ +export async function discoverAndIndex( + keeper: KeeperHarness, + user: Address, + timeoutMs = 10_000, +): Promise { + await discoverUser(keeper, user, timeoutMs); + await keeper.predictor.awaitIdle(); +} + +/** + * Drive one full scheduler sweep for `user`. Wraps `discoverUser` so + * tests don't repeat the same prelude every time. The sweep populates the + * coordinator queue from the tracker, and the executor's worker loop + * picks `user` up from there. + */ +export async function runOneSweep(keeper: KeeperHarness, user: Address): Promise { + await discoverUser(keeper, user); + await keeper.scheduler.runSweep(); +} + +// ───────────────────────────────────────────────────────────────────────── +// On-chain observation helpers +// ───────────────────────────────────────────────────────────────────────── + +export interface PerpsPosition { + /** Signed; positive = long, negative = short, zero = flat. */ + netQuantity: bigint; + aggregatedEntryPrice: bigint; +} + +export async function readPerpsPosition( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.perps, + abi: stack.abis.perps, + functionName: "getUserPosition", + args: [user], + })) as PerpsPosition; +} + +export async function readPerpsOrderIds( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.perps, + abi: stack.abis.perps, + functionName: "getUserOrders", + args: [user], + })) as readonly Hex[]; +} + +export async function readFuturesPositionIds( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getPositionIds", + args: [user], + })) as readonly Hex[]; +} + +export async function readFuturesOrderIds( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getOrderIds", + args: [user], + })) as readonly Hex[]; +} + +/** Resolves to true once `user` is flat on perps. */ +export async function expectPerpsClosed( + stack: DeployedStack, + user: Address, + timeoutMs = 30_000, +): Promise { + await waitFor(async () => (await readPerpsPosition(stack, user)).netQuantity === 0n, timeoutMs); +} + +/** Resolves to true once `user` has no futures positions. */ +export async function expectFuturesClosed( + stack: DeployedStack, + user: Address, + timeoutMs = 30_000, +): Promise { + await waitFor(async () => (await readFuturesPositionIds(stack, user)).length === 0, timeoutMs); +} + +/** Resolves to true once `user` has no open orders on either venue. */ +export async function expectNoOpenOrders( + stack: DeployedStack, + user: Address, + timeoutMs = 30_000, +): Promise { + await waitFor(async () => { + const [perps, futures] = await Promise.all([ + readPerpsOrderIds(stack, user), + readFuturesOrderIds(stack, user), + ]); + return perps.length === 0 && futures.length === 0; + }, timeoutMs); +} + +// ───────────────────────────────────────────────────────────────────────── +// PlanOutcome assertions +// ───────────────────────────────────────────────────────────────────────── + +/** + * Asserts `outcome.kind === "liquidated"` and returns it narrowed. The + * caller can then read `feeEarned`, `ordersClosed`, `positionsClosed` to + * verify *how* the planner closed the account (orders-only vs position + * loop vs both). + */ +export function expectLiquidated(outcome: PlanOutcome): Extract { + assert.equal( + outcome.kind, + "liquidated", + `expected liquidated outcome, got: ${formatOutcome(outcome)}`, + ); + return outcome as Extract; +} + +export function expectHealthy(outcome: PlanOutcome): Extract { + assert.equal(outcome.kind, "healthy", `expected healthy outcome, got: ${formatOutcome(outcome)}`); + return outcome as Extract; +} + +export function expectBadDebt(outcome: PlanOutcome): Extract { + assert.equal(outcome.kind, "badDebt", `expected badDebt outcome, got: ${formatOutcome(outcome)}`); + return outcome as Extract; +} + +export function expectActioned(outcome: PlanOutcome): void { + assert.ok( + outcome.kind === "liquidated" || outcome.kind === "badDebt", + `expected planner to settle the account (liquidated or badDebt), got: ${formatOutcome(outcome)}`, + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// Internals +// ───────────────────────────────────────────────────────────────────────── + +/** + * Polls `predicate` every 100ms until it returns truthy or `timeoutMs` + * elapses. Async predicates are supported; an internal `await` keeps us + * from re-entering the same RPC call concurrently. + */ +export async function waitFor( + predicate: () => boolean | Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await sleep(100); + } + throw new Error(`waitFor: predicate did not pass within ${timeoutMs}ms`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * `PlanOutcome` carries `bigint` fields (`mmSurplus`, `feeEarned`) which + * `JSON.stringify` chokes on. Format manually so failing asserts produce + * useful diagnostics rather than `TypeError: Do not know how to serialize + * a BigInt`. + */ +export function formatOutcome(outcome: PlanOutcome | { kind: string; [k: string]: unknown }): string { + const parts = Object.entries(outcome).map( + ([k, v]) => `${k}=${typeof v === "bigint" ? `${v}n` : JSON.stringify(v)}`, + ); + return `{ ${parts.join(", ")} }`; +} + +/** Type guard for alert webhook bodies — used by the notifier test. */ +export function isCriticalAlert(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + "severity" in body && + (body as { severity: unknown }).severity === "critical" + ); +} diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts new file mode 100644 index 0000000..0b6d2b5 --- /dev/null +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -0,0 +1,330 @@ +import { describe, it, before, after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + createTestClient, + http, + publicActions, + walletActions, + type TestClient, +} from "viem"; +import { hardhat } from "viem/chains"; +import { startHardhatNode, type HardhatNode } from "./nodeProcess.ts"; +import { loadFixture } from "./loadFixture.ts"; +import { buildKeeper, type KeeperHarness } from "./buildKeeper.ts"; +import { startWebhookSink } from "./webhookSink.ts"; +import { + aliceDepositFixtureBuilder, + perpsLongCrashFixtureBuilder, + futuresLongCrashFixtureBuilder, + futuresOrdersAndPositionFixtureBuilder, + multiFuturesFixtureBuilder, + crossVenueFixtureBuilder, +} from "./scenarios.ts"; +import { + discoverUser, + discoverAndIndex, + runOneSweep, + readFuturesOrderIds, + expectPerpsClosed, + expectFuturesClosed, + expectNoOpenOrders, + expectHealthy, + isCriticalAlert, + waitFor, +} from "./helpers.ts"; + +/** + * Integration test suite for `@collateral-margin/keeper`. + * + * Architecture: + * - One Hardhat node, started once (`before`) and stopped on suite exit. + * - Per-test isolation via `evm_snapshot` / `evm_revert` (see + * `loadFixture.ts`). Snapshots are keyed by fixture *function + * reference*, so each scenario closure is a top-level constant. + * - Each test rebuilds the keeper from scratch in-process (`buildKeeper`) + * against the live RPC, so cross-test leakage in in-memory caches is + * impossible. + * + * Each test body reads as a small spec: + * 1. Load a fixture that names the scenario (`perpsLongCrashFixture`). + * 2. Build + start the keeper. + * 3. Trigger the scenario action (e.g. `ctx.makeLiquidatable()`). + * 4. Assert what the keeper did using `expectXyz(outcome)` or + * `expect{Perps,Futures}Closed`. + * + * Prereq: sibling repos (perps + futures-marketplace) must be compiled. + * `pretest:integration` in `package.json` handles this. + */ + +let node: HardhatNode; +let testClient: TestClient; +let keeper: KeeperHarness | undefined; + +// Fixture closures held at module scope — see scenarios.ts for why. +let aliceDepositFixture: ReturnType; +let perpsLongCrashFixture: ReturnType; +let futuresLongCrashFixture: ReturnType; +let futuresOrdersAndPositionFixture: ReturnType; +let multiFuturesFixture: ReturnType; +let crossVenueFixture: ReturnType; + +before( + async () => { + node = await startHardhatNode(); + testClient = createTestClient({ + chain: hardhat, + mode: "hardhat", + transport: http(node.rpcUrl), + }) + .extend(publicActions) + .extend(walletActions); + aliceDepositFixture = aliceDepositFixtureBuilder(node.rpcUrl); + perpsLongCrashFixture = perpsLongCrashFixtureBuilder(node.rpcUrl); + futuresLongCrashFixture = futuresLongCrashFixtureBuilder(node.rpcUrl); + futuresOrdersAndPositionFixture = futuresOrdersAndPositionFixtureBuilder(node.rpcUrl); + multiFuturesFixture = multiFuturesFixtureBuilder(node.rpcUrl); + crossVenueFixture = crossVenueFixtureBuilder(node.rpcUrl); + }, + { timeout: 60_000 }, +); + +after(async () => { + await node?.stop(); +}); + +afterEach(async () => { + await keeper?.stop(); + keeper = undefined; +}); + +// ───────────────────────────────────────────────────────────────────────── +// Tracker discovery +// ───────────────────────────────────────────────────────────────────────── + +describe("ParticipantTracker (live RPC)", () => { + it("discovers a user from a Vault.Deposited event", { timeout: 30_000 }, async () => { + const ctx = await loadFixture(aliceDepositFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + await discoverUser(keeper, ctx.accounts.alice.account.address); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Perps-only liquidation scenarios +// ───────────────────────────────────────────────────────────────────────── + +describe("Perps liquidation", () => { + it( + "reports `healthy` when prices have not moved", + { timeout: 30_000 }, + async () => { + // Precondition: alice holds a perps long at the entry mark; oracle + // is *unchanged*, so the planner should never take action. + const ctx = await loadFixture(perpsLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await discoverUser(keeper, alice); + + const outcome = await keeper.planner.run(alice); + const healthy = expectHealthy(outcome); + assert.ok(healthy.mmSurplus >= 0n, "mmSurplus should be non-negative"); + }, + ); + + it( + "closes a deeply underwater long position", + { timeout: 60_000 }, + async () => { + // Precondition: alice's $100 deposit cannot cover ~$168 unrealized + // loss after a 99.8% hashprice crash. The planner runs the orders- + // leg (no-op) then closes her single position. + const ctx = await loadFixture(perpsLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + }, + ); + + // NOTE: this exercises the planner's orders-leg-then-position-leg flow + // on the perps venue. Currently blocked by a keeper/contract drift — + // `PerpsVenue.liquidateOrders` calls a batch `liquidateOrders(user, ids[])` + // entry point that was retired in favour of N `liquidateOrder` calls + // composed via `multicallStopOnFailure` (see + // perps/contracts/tests/liquidateOrdersAndPosition.test.ts). The keeper + // adapter needs to switch to that primitive before this scenario + // becomes pass-able. Until then the planner's two-leg flow is still + // exercised on the *futures* venue (see "Futures liquidation" below), + // which retains a native batch `liquidateOrders(user)`. + it.todo( + "cancels resting orders alongside the position liquidation (blocked on keeper#perps batch-orders gap)", + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Futures-only liquidation scenarios +// ───────────────────────────────────────────────────────────────────────── + +describe("Futures liquidation", () => { + it( + "closes a futures long after the hashprice crashes", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds a single long futures contract at the + // first delivery date. The unrealized loss is `(entryPrice − marketPrice) + // · deliveryDurationDays · qty`; sized so the deposit can't cover it. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectFuturesClosed(ctx, alice); + }, + ); + + it( + "cancels resting orders before closing the futures position", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds a long futures position AND a stale + // far-out-of-market resting buy order. After the crash the planner + // must run orders-leg (FIFO sweep via `liquidateOrders(user)`) and + // position-leg in the same plan; we verify on-chain that both + // legs end up empty. + const ctx = await loadFixture(futuresOrdersAndPositionFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + assert.equal( + (await readFuturesOrderIds(ctx, alice)).length, + ctx.restingOrderCount, + "test precondition: alice should have a resting futures order at fixture time", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectFuturesClosed(ctx, alice); + await expectNoOpenOrders(ctx, alice); + }, + ); + + it( + "iterates the position loop to close multiple delivery dates", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds long futures across two delivery dates. + // The planner's worst-first loop must run at least twice (once per + // position) before the account becomes healthy. End state: no + // futures positions remain. + const ctx = await loadFixture(multiFuturesFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectFuturesClosed(ctx, alice); + // Bonus: no straggler orders left in the book either. + assert.equal((await readFuturesOrderIds(ctx, alice)).length, 0); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Cross-venue coordination +// ───────────────────────────────────────────────────────────────────────── + +describe("Cross-venue coordination", () => { + it( + "closes both perps and futures legs of an underwater account", + { timeout: 60_000 }, + async () => { + // Precondition: alice is simultaneously long perps + long futures + // (same hashprice). A single oracle move puts both legs underwater + // and the planner must coordinate across venues. The contract under + // test is: both legs end up flat from a single sweep — neither + // venue is left stranded just because the other one's closure made + // alice momentarily healthy on a different venue's MM math. + const ctx = await loadFixture(crossVenueFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + await expectFuturesClosed(ctx, alice); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Predictor-driven liquidation (event path, no scheduler sweep) +// ───────────────────────────────────────────────────────────────────────── + +describe("PredictiveCoordinator (live oracle events)", () => { + it( + "drives liquidation via AnswerUpdated alone (scheduler sweep disabled)", + { timeout: 30_000 }, + async () => { + // Precondition: alice holds a healthy perps long. We never call + // `scheduler.runSweep()` — if the position closes, the only path + // was `BTC/USDC AnswerUpdated` → PriceFeed → PredictiveCoordinator + // → Queue → CoordinatorExecutor → Planner. + const ctx = await loadFixture(perpsLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + // The predictor needs alice's *pre-crash* thresholds indexed before + // we move the oracle, otherwise `solveLiquidationThresholds` would + // short-circuit on an underwater snapshot. + await discoverAndIndex(keeper, alice); + + await ctx.makeLiquidatable(); + + await expectPerpsClosed(ctx, alice); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Alert notifier +// ───────────────────────────────────────────────────────────────────────── + +describe("Notifier (live HTTP)", () => { + it( + "POSTs a critical alert when the account crosses the MM threshold", + { timeout: 30_000 }, + async () => { + const ctx = await loadFixture(perpsLongCrashFixture, testClient); + const sink = await startWebhookSink(); + try { + keeper = buildKeeper(ctx, { webhookUrl: sink.url }); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await waitFor(() => sink.received.some((r) => isCriticalAlert(r.body)), 15_000); + } finally { + await sink.stop(); + } + }, + ); +}); diff --git a/keeper/tests/integration/loadFixture.ts b/keeper/tests/integration/loadFixture.ts new file mode 100644 index 0000000..5ee8070 --- /dev/null +++ b/keeper/tests/integration/loadFixture.ts @@ -0,0 +1,81 @@ +import type { Hex, TestClient } from "viem"; + +/** + * Viem-native re-implementation of Hardhat's `loadFixture`. + * + * First call: run the fixture fn, take `evm_snapshot`, + * cache `{ snapshotId, data }` keyed by fn. + * Subsequent calls (same fn ref): `evm_revert` to the cached snapshot, then + * immediately re-`evm_snapshot` (Hardhat's + * snapshots are consumed on revert, so we + * have to retake one), return cached data. + * + * The cache is keyed by the fixture function *reference*, so each scenario + * gets its own snapshot. Two different fixtures that build on a shared + * baseline don't share snapshots — they each pay the full deploy cost on + * first invocation, then are O(1) thereafter. That's the same trade-off + * Hardhat makes; it keeps the snapshot bookkeeping trivial. + * + * The fixture's return value (typically the deploy addresses + viem + * contract handles) survives revert because EVM state including deployer + * nonces is rolled back too — addresses are deterministic in `evm_revert`'s + * world view, so cached contract handles keep working. + */ +type FixtureFn = () => Promise; + +interface CacheEntry { + snapshotId: Hex; + data: unknown; +} + +const cache = new WeakMap, CacheEntry>(); + +/** + * Drop the snapshot cache. Useful between test files when a previous file + * mutated state that the next file's fixture should not inherit. Ordinary + * intra-file usage doesn't need this — same-fixture calls revert correctly. + */ +export function resetFixtureCache(): void { + // WeakMap has no clear() — replace by re-initialising. We hold the only + // reference, so the old map is GC-able as soon as we drop the binding. + for (const k of __keys()) cache.delete(k); +} + +const allKeys: FixtureFn[] = []; +function __keys(): readonly FixtureFn[] { + return allKeys; +} + +/** + * Load (or revert to) a fixture's snapshot. Pass the same `fn` reference on + * every call — wrapping the fixture in a lambda each test case defeats the + * cache and reverts to "redeploy on every test". + */ +export async function loadFixture(fn: FixtureFn, tc: TestClient): Promise { + const cached = cache.get(fn as FixtureFn); + if (cached !== undefined) { + // `revert` returns true on success; if it fails (e.g. snapshot ID + // invalidated by another revert path) we fall through to redeploy. + const reverted = (await tc.request({ + method: "evm_revert", + params: [cached.snapshotId] as unknown as never, + })) as unknown as boolean; + if (reverted) { + const fresh = (await tc.request({ + method: "evm_snapshot", + params: [] as unknown as never, + })) as unknown as Hex; + cache.set(fn as FixtureFn, { snapshotId: fresh, data: cached.data }); + return cached.data as T; + } + cache.delete(fn as FixtureFn); + } + const data = await fn(); + const snapshotId = (await tc.request({ + method: "evm_snapshot", + params: [] as unknown as never, + })) as unknown as Hex; + cache.set(fn as FixtureFn, { snapshotId, data }); + allKeys.push(fn as FixtureFn); + return data; +} diff --git a/keeper/tests/integration/nodeProcess.ts b/keeper/tests/integration/nodeProcess.ts new file mode 100644 index 0000000..852d98b --- /dev/null +++ b/keeper/tests/integration/nodeProcess.ts @@ -0,0 +1,149 @@ +import { spawn, type ChildProcess } from "node:child_process"; +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`). + * + * 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 + * upgradeable, chainlink, solidity-linked-list, `hardhat/console.sol`) that + * resolves correctly only inside those repos' own `node_modules/`. Forcing + * keeper to compile their `.sol` files would mean replicating their entire + * compile-time dep graph here. Instead, `pretest:integration` runs the + * sibling repos' own `pnpm hardhat compile` invocations and we just read the + * resulting artifact JSON via filesystem paths. + */ +export interface HardhatNode { + process: ChildProcess; + rpcUrl: string; + stop(): Promise; +} + +const DEFAULT_RPC_URL = "http://127.0.0.1:8545"; +const READY_TIMEOUT_MS = 30_000; +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 + * `collateral-margin/contracts/` (`../../contracts` relative to this file). + */ + hardhatProjectDir?: string; + rpcUrl?: string; + readyTimeoutMs?: number; + /** + * Forward node stdout/stderr to the parent process. Disabled by default + * because Hardhat's banner is noisy and would interleave with `node --test` + * output. Tests can opt in for debugging. + */ + verbose?: boolean; +} + +/** + * Spawn a fresh hardhat node and resolve once it responds to `eth_chainId`. + * The returned `stop()` kills the entire process group so child Hardhat + * tasks don't outlive the test run. + */ +export async function startHardhatNode( + options: StartHardhatNodeOptions = {}, +): Promise { + const cwd = options.hardhatProjectDir ?? resolve(import.meta.dirname, "../../../contracts"); + 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"], + }); + + // 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 + // ready-timeout error can include them. + let earlyStderr = ""; + if (!options.verbose) { + proc.stderr?.setEncoding("utf-8"); + proc.stderr?.on("data", (chunk: string) => { + if (earlyStderr.length < 256) earlyStderr += chunk; + }); + } + + // If hardhat dies before we see `eth_chainId` respond, surface that error + // rather than letting the caller wait the full ready timeout. + let exited = false; + let exitCode: number | null = null; + proc.once("exit", (code) => { + exited = true; + exitCode = code; + }); + + const pc = createPublicClient({ transport: http(rpcUrl, { timeout: 2_000, retryCount: 0 }) }); + + const deadline = Date.now() + readyTimeoutMs; + while (Date.now() < deadline) { + if (exited) { + throw new Error( + `hardhat node exited with code ${exitCode} before becoming ready` + + (earlyStderr ? `\nstderr: ${earlyStderr.trim()}` : ""), + ); + } + try { + await pc.getChainId(); + return { + process: proc, + rpcUrl, + stop: () => stopProcess(proc), + }; + } catch { + // Not ready yet — back off. + } + await sleep(POLL_INTERVAL_MS); + } + + await stopProcess(proc); + throw new Error( + `hardhat node did not respond to eth_chainId within ${readyTimeoutMs}ms` + + (earlyStderr ? `\nstderr: ${earlyStderr.trim()}` : ""), + ); +} + +function stopProcess(proc: ChildProcess): Promise { + return new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode !== null) { + resolve(); + return; + } + proc.once("close", () => resolve()); + try { + // Negative PID == process group. `detached: true` made us the leader. + process.kill(-proc.pid!, "SIGTERM"); + } catch (err) { + // Already dead, race with `once("close")`. + if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err; + resolve(); + } + // Hard kill after 5s if SIGTERM didn't take. + setTimeout(() => { + if (proc.exitCode === null && proc.signalCode === null) { + try { + process.kill(-proc.pid!, "SIGKILL"); + } catch { + /* ignore */ + } + } + }, 5_000).unref(); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts new file mode 100644 index 0000000..3259d30 --- /dev/null +++ b/keeper/tests/integration/scenarios.ts @@ -0,0 +1,477 @@ +import { parseUnits, type Address } from "viem"; +import { hardhat } from "viem/chains"; +import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; + +/** + * Fixture builders. + * + * Each builder returns a closure (`(): Promise`). The closure + * itself is the cache key used by `loadFixture` — tests must hold the + * returned closure in module scope, not recreate it per-test, otherwise + * the snapshot cache won't engage and every test pays the full deploy + * cost. + * + * Fixtures encode a *business state*, not just deploys: who has + * deposited, who holds which positions, what stale orders are still + * resting, what oracle level the markets sit at. The matching `make…` + * method on each fixture triggers the price move that should turn the + * scenario "interesting" (i.e. liquidatable). Tests then ask the keeper + * what it did and helpers in `helpers.ts` translate that into specific + * `assert` calls. + * + * Naming convention — every public builder is `FixtureBuilder` + * so the test file reads naturally: + * `const f = perpsLongCrashFixtureBuilder(rpcUrl);` + * `const ctx = await loadFixture(f, testClient);` + * `await ctx.makeLiquidatable();` + */ + +// ───────────────────────────────────────────────────────────────────────── +// Public fixture types +// ───────────────────────────────────────────────────────────────────────── + +export interface BaseFixture extends DeployedStack { + bumpHashprice(newPrice: bigint): Promise; + bumpBtcUsdc(newPrice: bigint): Promise; + /** Deposit USDC into the vault from the given (test-known) wallet. */ + deposit(userAddr: Address, amount: bigint): Promise; + /** Crash both oracles in lockstep so the predictor reacts via BTC/USDC. */ + crashOracles(hashpricePrice: bigint): Promise; +} + +export interface AliceDepositFixture extends BaseFixture { + aliceDeposit: bigint; +} + +/** Alice holds a perps long that is healthy at the entry price. */ +export interface PerpsLongFixture extends BaseFixture { + aliceDeposit: bigint; + aliceQty: bigint; + /** Crash hashprice + BTC/USDC so Alice's long becomes liquidatable. */ + makeLiquidatable(): Promise; +} + +/** Alice holds a perps long *and* a resting buy order that didn't match. */ +export interface PerpsOrdersAndPositionFixture extends PerpsLongFixture { + /** Count of resting (unmatched) orders Alice has after setup. */ + restingOrderCount: number; +} + +/** Alice holds a long futures contract (1 unit @ first delivery date). */ +export interface FuturesLongFixture extends BaseFixture { + aliceDeposit: bigint; + aliceFuturesQty: number; + makeLiquidatable(): Promise; +} + +/** Alice holds futures longs across multiple delivery dates. */ +export interface MultiFuturesFixture extends BaseFixture { + aliceDeposit: bigint; + deliveryDates: readonly bigint[]; + makeLiquidatable(): Promise; +} + +/** Alice holds a futures long AND has a resting (unmatched) buy order. */ +export interface FuturesOrdersAndPositionFixture extends FuturesLongFixture { + /** Count of resting orders held by alice at fixture time. */ + restingOrderCount: number; +} + +/** Alice has both perps + futures legs underwater after the crash. */ +export interface CrossVenueFixture extends BaseFixture { + aliceDeposit: bigint; + alicePerpsQty: bigint; + aliceFuturesQty: number; + makeLiquidatable(): Promise; +} + +// ───────────────────────────────────────────────────────────────────────── +// Base fixture +// ───────────────────────────────────────────────────────────────────────── + +export async function baseFixture(rpcUrl: string): Promise { + const stack = await deployStack(rpcUrl); + return { + ...stack, + bumpHashprice: (price) => writeOracle(stack, stack.addresses.hashpriceOracle, price), + bumpBtcUsdc: (price) => writeOracle(stack, stack.addresses.btcUsdcFeed, price), + deposit: (user, amount) => depositTo(stack, user, amount), + /** + * Apply a fresh hashprice and a *paired* BTC/USDC tick. The predictor + * only listens to the BTC/USDC channel, so the second write is what + * makes the event-driven liquidation path observable; the hashprice + * write is what actually moves PnL. + */ + crashOracles: async (hashpricePrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + const movedBtc = (stack.config.initialBtcUsdc * 9n) / 10n; + await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); + }, + }; +} + +// ───────────────────────────────────────────────────────────────────────── +// Scenario builders +// ───────────────────────────────────────────────────────────────────────── + +/** + * Alice has just deposited collateral — no orders, no positions. Useful + * only for verifying that the tracker discovers her via `Vault.Deposited`. + */ +export function aliceDepositFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("100", base.config.tokenDecimals); + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + return { ...base, aliceDeposit }; + }; +} + +/** + * Alice holds a perps long that survives the entry-price IM check but is + * deeply liquidatable after a price crash. + * + * Sizing math (see `_computeMargin` in PME): + * - IM at 10% shock: 40 · 0.10 · $4.21 = $16.84 → fits in $100 deposit. + * - After crash to $0.01: unrealized loss = ($4.21 − $0.01) · 40 = $168. + * - Vault balance ($100) < MM (~$168) ⇒ liquidatable by ~$68. + */ +export function perpsLongCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("100", base.config.tokenDecimals); + const bobDeposit = parseUnits("2000", base.config.tokenDecimals); + const aliceQty = parseUnits("40", base.config.quantityDecimals); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: aliceQty, + }); + + return { + ...base, + aliceDeposit, + aliceQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * Same as `perpsLongCrashFixtureBuilder` but at entry-time Alice *also* + * places a far-away resting buy order that never matched. After the + * crash the planner should walk the orders-leg first (cancelling the + * resting order) and then the position-leg. + * + * The resting order's price is set below `minimumPriceIncrement * 1` + * relative to the market so it can never cross with bob's bids in the + * book — it's a deliberate stale-quote scenario. + */ +export function perpsOrdersAndPositionFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + // Same balance as `perpsLongCrashFixtureBuilder` — sized so the + // post-crash MM ($168) exceeds the $100 deposit. Adding a resting + // order on top barely moves IM at entry but lets us verify that + // the planner walks the orders-leg as part of the same plan. + const aliceDeposit = parseUnits("100", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const aliceQty = parseUnits("40", base.config.quantityDecimals); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: aliceQty, + }); + + const restingPrice = parseUnits("1.00", base.config.oracleDecimals); + const restingQty = parseUnits("5", base.config.quantityDecimals); + await placePerpsOrder(base, base.accounts.alice, restingPrice, restingQty); + + return { + ...base, + aliceDeposit, + aliceQty, + restingOrderCount: 1, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * Alice holds a long futures contract at the first delivery date; Bob is + * the matched seller. Position PnL accrues per day across the full + * delivery window: at entry 4.21 / day × 7 days = $29.47 notional per + * unit. A crash to 0.01 puts ($4.20 × 7) = $29.40 of unrealized loss per + * unit — sized so 12 units exceed Alice's $200 deposit. + */ +export function futuresLongCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const bobDeposit = parseUnits("2000", base.config.tokenDecimals); + const aliceFuturesQty = 12; + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * Same as `futuresLongCrashFixtureBuilder` but Alice *also* places a + * far-out-of-market resting buy order before the crash. The order never + * matches (Bob doesn't offer a sell at $2/day), so it sits on the book + * until the planner walks the orders-leg. After the crash, the planner + * must run: + * 1. `liquidateOrders(user)` on futures (FIFO sweep) → cancels the + * resting order; + * 2. `liquidatePosition(user, id)` → cash-settles the position. + */ +export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const bobDeposit = parseUnits("2000", base.config.tokenDecimals); + const aliceFuturesQty = 12; + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + // Stale buy order well below the current mark — no counterparty + // exists at this price level so the order rests on the book. + const restingPrice = parseUnits("2.00", base.config.oracleDecimals); + await placeFuturesOrder( + base, + base.accounts.alice, + restingPrice, + base.config.futuresFirstDeliveryDate, + 1, + ); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + restingOrderCount: 1, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * Alice holds futures longs on *two* different delivery dates. After the + * crash, the planner must iterate the position loop more than once + * (worst-first by unrealized loss) and end with both positions closed. + */ +export function multiFuturesFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const firstDeliveryAt = base.config.futuresFirstDeliveryDate; + const secondDeliveryAt = + firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_DELIVERY_INTERVAL_DAYS`. + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + for (const deliveryAt of [firstDeliveryAt, secondDeliveryAt]) { + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt, + quantity: 6, + }); + } + + return { + ...base, + aliceDeposit, + deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * Alice holds simultaneous perps + futures longs. A single oracle move + * puts both legs underwater at once, exercising the planner's coordinated + * cross-venue ranking. + * + * Sized so the perps leg is the worst (largest `unrealizedLoss`) and the + * futures leg is meaningful but secondary — the planner closes the perps + * position first, then loops to clear futures. + */ +export function crossVenueFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const bobDeposit = parseUnits("5000", base.config.tokenDecimals); + const alicePerpsQty = parseUnits("100", base.config.quantityDecimals); + const aliceFuturesQty = 1; + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +// ───────────────────────────────────────────────────────────────────────── +// Internal placement / writing helpers +// ───────────────────────────────────────────────────────────────────────── + +interface PerpsTrade { + buyer: Wallet; + seller: Wallet; + price: bigint; + /** Always positive — sign is derived per leg. */ + quantity: bigint; +} + +/** + * Cross a perps order at `price` between `buyer` (positive qty) and + * `seller` (negative qty). Seller's resting short is placed *first* so + * the taker (`buyer`) matches against it on submission. + */ +async function matchPerpsTrade(base: BaseFixture, t: PerpsTrade): Promise { + await placePerpsOrder(base, t.seller, t.price, -t.quantity); + await placePerpsOrder(base, t.buyer, t.price, t.quantity); +} + +interface FuturesTrade { + buyer: Wallet; + seller: Wallet; + price: bigint; + deliveryAt: bigint; + /** int8 — number of contracts. */ + quantity: number; +} + +/** Same shape as `matchPerpsTrade`, but for the Futures venue. */ +async function matchFuturesTrade(base: BaseFixture, t: FuturesTrade): Promise { + await placeFuturesOrder(base, t.seller, t.price, t.deliveryAt, -t.quantity); + await placeFuturesOrder(base, t.buyer, t.price, t.deliveryAt, t.quantity); +} + +async function placePerpsOrder( + base: BaseFixture, + wallet: Wallet, + price: bigint, + quantity: bigint, +): Promise { + const hash = await wallet.client.writeContract({ + address: base.addresses.perps, + abi: base.abis.perps, + functionName: "createOrder", + args: [price, quantity], + chain: hardhat, + account: wallet.account, + }); + await base.publicClient.waitForTransactionReceipt({ hash }); +} + +async function placeFuturesOrder( + base: BaseFixture, + wallet: Wallet, + price: bigint, + deliveryAt: bigint, + qty: number, +): Promise { + // Futures takes a packed (price, deliveryDate, destURL, qty) tuple. + // `qty` is `int8` — positive = buyer-side, negative = seller-side. + const hash = await wallet.client.writeContract({ + address: base.addresses.futures, + abi: base.abis.futures, + functionName: "createOrder", + args: [price, deliveryAt, "//keeper-test", qty], + chain: hardhat, + account: wallet.account, + }); + await base.publicClient.waitForTransactionReceipt({ hash }); +} + +async function writeOracle(stack: DeployedStack, oracle: Address, price: bigint): Promise { + const hash = await stack.accounts.owner.client.writeContract({ + address: oracle, + abi: stack.abis.hashpriceOracle, + functionName: "setAnswer", + args: [price], + chain: hardhat, + account: stack.accounts.owner.account, + }); + await stack.publicClient.waitForTransactionReceipt({ hash }); +} + +async function depositTo(stack: DeployedStack, user: Address, amount: bigint): Promise { + const wallet = Object.values(stack.accounts).find((w) => w.account.address === user); + if (wallet === undefined) throw new Error(`No fixture wallet for ${user}`); + const hash = await wallet.client.writeContract({ + address: stack.addresses.vault, + abi: stack.abis.vault, + functionName: "deposit", + args: [amount], + chain: hardhat, + account: wallet.account, + }); + await stack.publicClient.waitForTransactionReceipt({ hash }); +} diff --git a/keeper/tests/integration/webhookSink.ts b/keeper/tests/integration/webhookSink.ts new file mode 100644 index 0000000..45e24ed --- /dev/null +++ b/keeper/tests/integration/webhookSink.ts @@ -0,0 +1,64 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { once } from "node:events"; + +/** + * Tiny HTTP sink the `Notifier` alert test posts into. Listens on port 0 + * (kernel-assigned ephemeral port) so multiple tests can run in parallel + * without collisions, and records every JSON body it receives. + * + * Why local-fake rather than `nock` or similar: the keeper's `Notifier` + * uses Node's built-in `fetch` which can't be intercepted by transport + * mocks. A real socket server is simpler and exercises the same code path + * the production keeper uses. + */ +export interface WebhookSink { + url: string; + received: ReceivedRequest[]; + stop(): Promise; +} + +export interface ReceivedRequest { + body: unknown; + /** ms-since-epoch timestamp set when the body finished arriving. */ + at: number; +} + +export async function startWebhookSink(): Promise { + const received: ReceivedRequest[] = []; + const server: Server = createServer((req, res) => { + void readBody(req).then((body) => { + received.push({ body, at: Date.now() }); + res.statusCode = 204; + res.end(); + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const addr = server.address(); + if (addr === null || typeof addr === "string") { + throw new Error("webhook sink failed to bind a TCP port"); + } + const url = `http://127.0.0.1:${addr.port}/`; + + return { + url, + received, + stop: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString("utf-8"); + if (raw.length === 0) return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} From ef9fd94f305c18d7904b85e008b1ba7ef5d18891 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 14 May 2026 23:53:09 +0200 Subject: [PATCH 022/180] feat: enhance PerpsVenue with multicall liquidations and add integration tests for multi-account scenarios --- keeper/src/venues/perps.ts | 119 ++++++- keeper/tests/integration/deployStack.ts | 12 +- keeper/tests/integration/helpers.ts | 45 +++ .../integration/keeper.integration.test.ts | 321 +++++++++++++++++- keeper/tests/integration/scenarios.ts | 266 ++++++++++++++- 5 files changed, 714 insertions(+), 49 deletions(-) diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index 76f24b4..da7617b 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -1,4 +1,13 @@ -import { keccak256, pad, toHex, type Address, type Hex } from "viem"; +import { + encodeFunctionData, + keccak256, + pad, + parseEventLogs, + toHex, + type Address, + type Hex, + type TransactionReceipt, +} from "viem"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; @@ -93,11 +102,28 @@ export class PerpsVenue implements Venue { ]; } + /** + * Cancels every supplied resting order via a single + * `multicallStopOnFailure([liquidateOrder(user, id), ...])` transaction. + * + * The perps contract retired the dedicated batch entry point + * `liquidateOrders(user, ids[])`; the canonical replacement is N + * `liquidateOrder` sub-calls composed through + * {MulticallStopOnFailureUpgradeable}. The multicall: + * + * - Stops at the first sub-call that reverts (e.g. `NotLiquidatable` + * once cancelling earlier orders restored MM mid-batch). Earlier + * sub-calls keep their state changes and emit their `OrderLiquidated` + * events — we still pocket those fees. + * - Does *not* revert the whole tx for clean sub-call reverts, so the + * "user is healthy, do nothing" case requires inspecting the + * simulation's `successes` array rather than relying on a top-level + * throw. + * - Reverts the whole batch with `MulticallSubCallOutOfGas` on an + * empty-revert sub-call (typically OOG) — we let that bubble up so + * the executor re-queues. + */ async liquidateOrders(user: Address, ids?: readonly Hex[]): Promise { - // Perps is the only venue that takes a calldata id list — futures sweeps - // FIFO. If the planner doesn't provide ids we fetch them ourselves so the - // contract has something to chew on (the Multicall3 batching path also - // benefits from a static id list). let targetIds = ids; if (targetIds === undefined) { const fetched = (await this.chain.publicClient.readContract({ @@ -110,23 +136,59 @@ export class PerpsVenue implements Venue { } if (targetIds.length === 0) { - // Nothing to cancel — surface as `notLiquidatable` so the planner can - // bail on this leg without rolling back the wider plan. + // Nothing to cancel — surface as `notLiquidatable` so the planner + // can bail on this leg without rolling back the wider plan. return { skipped: "notLiquidatable" }; } - const result = await sendLiquidate({ - chain: this.chain, - config: this.config, - logger: this.logger, + const calls = targetIds.map((orderId) => + encodeFunctionData({ + abi: HashPowerPerpsDEXAbi, + functionName: "liquidateOrder", + args: [user, orderId], + }), + ); + + // Simulate first — `multicallStopOnFailure` never propagates a + // sub-call revert as a top-level revert, so the only way to detect + // "user is healthy, every sub-call would clean-revert" is to read + // `successes[0]` from the simulated return. + const sim = await this.chain.publicClient.simulateContract({ address: this.config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "liquidateOrders", - args: [user, targetIds], - feeEventName: "OrderLiquidated", + functionName: "multicallStopOnFailure", + args: [calls], + account: this.chain.account, }); + const successes = (sim.result as readonly [readonly boolean[], readonly Hex[]])[0]; + if (successes[0] === false) { + this.logger.debug( + { user, ordersTargeted: targetIds.length }, + "perps batch liquidate skipped — first sub-call would revert (user healthy)", + ); + return { skipped: "notLiquidatable" }; + } + + if (this.config.keeper.dryRun) { + this.logger.info( + { user, ordersTargeted: targetIds.length }, + "[dryRun] would send perps batch liquidate", + ); + return { feeEarned: 0n }; + } - return "skipped" in result ? { skipped: "notLiquidatable" } : { feeEarned: result.feeEarned }; + const hash = await this.chain.walletClient.writeContract(sim.request); + const receipt = await this.chain.publicClient.waitForTransactionReceipt({ + hash, + confirmations: this.config.coordinator.confirmationBlocks, + }); + const feeEarned = sumOrderLiquidatedFees(receipt); + const ordersClosed = countSuccesses(successes); + this.logger.info( + { user, hash, ordersClosed, feeEarned }, + "perps batch liquidate confirmed", + ); + return { feeEarned }; } async liquidatePosition(user: Address, _id: Hex): Promise { @@ -171,3 +233,30 @@ function abs(x: bigint): bigint { function perpsPositionId(user: Address): Hex { return pad(user, { size: 32 }); } + +/** + * Walks a `multicallStopOnFailure` receipt and sums the `fee` field of every + * `OrderLiquidated` event. The multicall delegatecalls each sub-call into + * the contract's own storage, so every successful `liquidateOrder` emits + * one event on the receipt — they accumulate naturally. + */ +function sumOrderLiquidatedFees(receipt: TransactionReceipt): bigint { + const logs = parseEventLogs({ + abi: HashPowerPerpsDEXAbi, + logs: receipt.logs, + eventName: "OrderLiquidated", + }); + let total = 0n; + for (const log of logs) { + const fee = log.args.fee; + if (typeof fee === "bigint") total += fee; + } + return total; +} + +/** Counts the truthy entries in the multicall's `successes` array. */ +function countSuccesses(successes: readonly boolean[]): number { + let n = 0; + for (const s of successes) if (s) n++; + return n; +} diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 7608306..c8ac03f 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -39,6 +39,7 @@ export const HARDHAT_PRIVATE_KEYS = [ "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", // #2 bob "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", // #3 liquidator "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", // #4 validator + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", // #5 dave (second test trader) ] as const satisfies readonly Hex[]; export interface Wallet { @@ -56,6 +57,8 @@ export interface DeployedStack { bob: Wallet; liquidator: Wallet; validator: Wallet; + /** Spare trader for multi-account scenarios. */ + dave: Wallet; }; addresses: { usdc: Address; @@ -149,12 +152,13 @@ export async function deployStack(rpcUrl: string): Promise { } satisfies Wallet; }); // Destructure with non-null assertions — the array literal above guarantees - // 5 elements, but TS can't see that through `Array.prototype.map`. + // 6 elements, but TS can't see that through `Array.prototype.map`. const owner = wallets[0]!; const alice = wallets[1]!; const bob = wallets[2]!; const liquidator = wallets[3]!; const validator = wallets[4]!; + const dave = wallets[5]!; // ── Infrastructure: Multicall3 ──────────────────────────────────────── // Deployed first because `buildKeeper` reads its address into the chain @@ -259,13 +263,13 @@ export async function deployStack(rpcUrl: string): Promise { ]); // ── Fund & approve test wallets ─────────────────────────────────────── - for (const w of [alice, bob, liquidator, validator]) { + for (const w of [alice, bob, liquidator, validator, dave]) { await write(publicClient, owner.client, usdc, usdcArt.abi, "transfer", [ w.account.address, INITIAL_USER_BALANCE, ]); } - for (const w of [owner, alice, bob, liquidator, validator]) { + for (const w of [owner, alice, bob, liquidator, validator, dave]) { await write(publicClient, w.client, usdc, usdcArt.abi, "approve", [vault, APPROVE_MAX]); } @@ -278,7 +282,7 @@ export async function deployStack(rpcUrl: string): Promise { publicClient, testClient, rpcUrl, - accounts: { owner, alice, bob, liquidator, validator }, + accounts: { owner, alice, bob, liquidator, validator, dave }, addresses: { usdc, hashpriceOracle, diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 62c4f91..8b3a547 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -157,6 +157,51 @@ export async function expectNoOpenOrders( }, timeoutMs); } +// ───────────────────────────────────────────────────────────────────────── +// Liquidation-event ordering helpers +// ───────────────────────────────────────────────────────────────────────── +// +// All four readers return the *earliest* block number a given event was +// emitted at for `user`, or `null` if no matching event was emitted. +// Tests then compare block numbers across helpers to assert the planner's +// invariants (orders-leg before position-leg, worst-leg first, etc). +// +// The perps event indexes `user`; the futures event indexes `participant`. +// viem doesn't auto-translate, so each helper passes the right kwarg. + +export const readPerpsPositionLiquidationBlock = (s: DeployedStack, u: Address) => + earliestEventBlock(s, "perps", "PositionLiquidated", { user: u }); + +export const readFuturesPositionLiquidationBlock = (s: DeployedStack, u: Address) => + earliestEventBlock(s, "futures", "PositionLiquidated", { participant: u }); + +export const readPerpsOrderLiquidationBlock = (s: DeployedStack, u: Address) => + earliestEventBlock(s, "perps", "OrderLiquidated", { user: u }); + +export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) => + earliestEventBlock(s, "futures", "OrderLiquidated", { participant: u }); + +async function earliestEventBlock( + stack: DeployedStack, + venue: "perps" | "futures", + eventName: "PositionLiquidated" | "OrderLiquidated", + args: Record, +): Promise { + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses[venue], + abi: stack.abis[venue], + eventName, + args, + fromBlock: 0n, + }); + let earliest: bigint | null = null; + for (const log of logs) { + if (log.blockNumber === null) continue; + if (earliest === null || log.blockNumber < earliest) earliest = log.blockNumber; + } + return earliest; +} + // ───────────────────────────────────────────────────────────────────────── // PlanOutcome assertions // ───────────────────────────────────────────────────────────────────────── diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 0b6d2b5..75a9142 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -15,16 +15,27 @@ import { startWebhookSink } from "./webhookSink.ts"; import { aliceDepositFixtureBuilder, perpsLongCrashFixtureBuilder, + perpsShortCrashFixtureBuilder, + perpsOrdersAndPositionFixtureBuilder, + twoUnderwaterUsersFixtureBuilder, futuresLongCrashFixtureBuilder, futuresOrdersAndPositionFixtureBuilder, multiFuturesFixtureBuilder, - crossVenueFixtureBuilder, + crossVenuePerpsDominantFixtureBuilder, + crossVenueFuturesDominantFixtureBuilder, + crossVenueOrdersAndPositionsFixtureBuilder, } from "./scenarios.ts"; import { discoverUser, discoverAndIndex, runOneSweep, + readPerpsOrderIds, readFuturesOrderIds, + readPerpsPositionLiquidationBlock, + readFuturesPositionLiquidationBlock, + readPerpsOrderLiquidationBlock, + readFuturesOrderLiquidationBlock, + readPerpsPosition, expectPerpsClosed, expectFuturesClosed, expectNoOpenOrders, @@ -63,10 +74,15 @@ let keeper: KeeperHarness | undefined; // Fixture closures held at module scope — see scenarios.ts for why. let aliceDepositFixture: ReturnType; let perpsLongCrashFixture: ReturnType; +let perpsShortCrashFixture: ReturnType; +let perpsOrdersAndPositionFixture: ReturnType; +let twoUnderwaterUsersFixture: ReturnType; let futuresLongCrashFixture: ReturnType; let futuresOrdersAndPositionFixture: ReturnType; let multiFuturesFixture: ReturnType; -let crossVenueFixture: ReturnType; +let crossVenuePerpsDominantFixture: ReturnType; +let crossVenueFuturesDominantFixture: ReturnType; +let crossVenueOrdersAndPositionsFixture: ReturnType; before( async () => { @@ -80,10 +96,15 @@ before( .extend(walletActions); aliceDepositFixture = aliceDepositFixtureBuilder(node.rpcUrl); perpsLongCrashFixture = perpsLongCrashFixtureBuilder(node.rpcUrl); + perpsShortCrashFixture = perpsShortCrashFixtureBuilder(node.rpcUrl); + perpsOrdersAndPositionFixture = perpsOrdersAndPositionFixtureBuilder(node.rpcUrl); + twoUnderwaterUsersFixture = twoUnderwaterUsersFixtureBuilder(node.rpcUrl); futuresLongCrashFixture = futuresLongCrashFixtureBuilder(node.rpcUrl); futuresOrdersAndPositionFixture = futuresOrdersAndPositionFixtureBuilder(node.rpcUrl); multiFuturesFixture = multiFuturesFixtureBuilder(node.rpcUrl); - crossVenueFixture = crossVenueFixtureBuilder(node.rpcUrl); + crossVenuePerpsDominantFixture = crossVenuePerpsDominantFixtureBuilder(node.rpcUrl); + crossVenueFuturesDominantFixture = crossVenueFuturesDominantFixtureBuilder(node.rpcUrl); + crossVenueOrdersAndPositionsFixture = crossVenueOrdersAndPositionsFixtureBuilder(node.rpcUrl); }, { timeout: 60_000 }, ); @@ -153,18 +174,100 @@ describe("Perps liquidation", () => { }, ); - // NOTE: this exercises the planner's orders-leg-then-position-leg flow - // on the perps venue. Currently blocked by a keeper/contract drift — - // `PerpsVenue.liquidateOrders` calls a batch `liquidateOrders(user, ids[])` - // entry point that was retired in favour of N `liquidateOrder` calls - // composed via `multicallStopOnFailure` (see - // perps/contracts/tests/liquidateOrdersAndPosition.test.ts). The keeper - // adapter needs to switch to that primitive before this scenario - // becomes pass-able. Until then the planner's two-leg flow is still - // exercised on the *futures* venue (see "Futures liquidation" below), - // which retains a native batch `liquidateOrders(user)`. - it.todo( - "cancels resting orders alongside the position liquidation (blocked on keeper#perps batch-orders gap)", + it( + "closes an underwater short position when the price rises", + { timeout: 60_000 }, + async () => { + // Precondition: alice is SHORT 40 perps at $4.21. A 2× price pump + // to $8.42 puts her short ~$168 underwater on a $100 deposit. This + // is the mirror of `closes a deeply underwater long position` and + // pins down the PnL sign handling in `PerpsVenue.readPositions` + // — `netQuantity < 0` ⇒ short ⇒ loss when price moves *up*. + const ctx = await loadFixture(perpsShortCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + }, + ); + + it( + "cancels resting orders alongside the position liquidation", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds a perps long AND a stale far-out-of- + // market resting buy order. The perps venue cancels the resting + // order via `multicallStopOnFailure([liquidateOrder(user, id)])` + // (the contract retired the batch `liquidateOrders` entry point); + // the planner then walks the position-leg in the same plan. + const ctx = await loadFixture(perpsOrdersAndPositionFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + assert.equal( + (await readPerpsOrderIds(ctx, alice)).length, + ctx.restingOrderCount, + "test precondition: alice should have a resting perps order at fixture time", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + await expectNoOpenOrders(ctx, alice); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Multi-account coordination (queue priority, serialized execution) +// ───────────────────────────────────────────────────────────────────────── + +describe("Multi-account coordination", () => { + it( + "liquidates both underwater users, worst-mmSurplus first", + { timeout: 60_000 }, + async () => { + // Precondition: alice and dave are both long perps with the same + // deposit ($100) but different sizes — alice is 40-qty (~$168 + // loss after crash), dave is 20-qty (~$84 loss). Post-crash + // `mmSurplus` is more negative for alice. + // + // The queue is a priority queue ordered by `mmSurplus` ASC, so + // alice must be popped first. With `maxConcurrentAccounts: 1` + // (default) the executor processes them serially — alice closes + // first, then dave. We assert ordering via the block numbers of + // their respective `PositionLiquidated` events. + const ctx = await loadFixture(twoUnderwaterUsersFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + // Both users are pre-existing in the fixture snapshot; seed them + // directly into the tracker rather than relying on the `Deposited` + // watcher to back-scan. This test is about queue priority and the + // executor's serial behavior, not tracker discovery (covered above). + keeper.tracker.add(ctx.worseUser); + keeper.tracker.add(ctx.betterUser); + + await ctx.makeLiquidatable(); + await keeper.scheduler.runSweep(); + + await expectPerpsClosed(ctx, ctx.worseUser); + await expectPerpsClosed(ctx, ctx.betterUser); + + const worseBlock = await readPerpsPositionLiquidationBlock(ctx, ctx.worseUser); + const betterBlock = await readPerpsPositionLiquidationBlock(ctx, ctx.betterUser); + assert.ok(worseBlock !== null && betterBlock !== null); + assert.ok( + worseBlock <= betterBlock, + `expected worse-mmSurplus user liquidated first, got worse=${worseBlock} better=${betterBlock}`, + ); + }, ); }); @@ -258,7 +361,7 @@ describe("Cross-venue coordination", () => { // test is: both legs end up flat from a single sweep — neither // venue is left stranded just because the other one's closure made // alice momentarily healthy on a different venue's MM math. - const ctx = await loadFixture(crossVenueFixture, testClient); + const ctx = await loadFixture(crossVenuePerpsDominantFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); @@ -270,6 +373,130 @@ describe("Cross-venue coordination", () => { await expectFuturesClosed(ctx, alice); }, ); + + it( + "liquidates the perps leg first when perps unrealized loss dominates", + { timeout: 60_000 }, + async () => { + // Precondition: 100-qty perps long ($420 loss) + 1-unit futures + // long ($29.40 loss). The planner's `rankPositions` orders by + // `unrealizedLoss DESC`, so perps must be closed strictly before + // futures. Observable signal: the block number of the perps + // `PositionLiquidated` event is strictly less than the futures one. + const ctx = await loadFixture(crossVenuePerpsDominantFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + await expectFuturesClosed(ctx, alice); + + const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); + const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); + assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); + assert.ok( + perpsBlock < futuresBlock, + `expected perps liquidated before futures, got perps=${perpsBlock} futures=${futuresBlock}`, + ); + }, + ); + + it( + "liquidates the futures leg first when futures unrealized loss dominates", + { timeout: 60_000 }, + async () => { + // Precondition: inverted from the previous test — 1-qty perps long + // ($4.20 loss) + 20-unit futures long ($588 loss across the 7-day + // delivery window). Futures must be closed strictly before perps, + // confirming the planner's ranking is by loss size and not by a + // hard-coded venue order. + const ctx = await loadFixture(crossVenueFuturesDominantFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectPerpsClosed(ctx, alice); + await expectFuturesClosed(ctx, alice); + + const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); + const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); + assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); + assert.ok( + futuresBlock < perpsBlock, + `expected futures liquidated before perps, got perps=${perpsBlock} futures=${futuresBlock}`, + ); + }, + ); + + it( + "liquidates orders across every venue before touching any position", + { timeout: 60_000 }, + async () => { + // Precondition: alice has positions on both venues AND a stale + // resting order on each book. After the crash, the planner's + // contract is: + // + // 1. orders-leg fans out across every venue (perps then + // futures) and cancels open orders; + // 2. only THEN does position-leg run and start closing + // positions worst-first. + // + // Observable invariant: every `OrderLiquidated` event lives in a + // block ≤ every `PositionLiquidated` event, on either venue. We + // pick the latest order block and the earliest position block and + // compare — that catches any interleaving regression. + const ctx = await loadFixture(crossVenueOrdersAndPositionsFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + assert.equal( + (await readPerpsOrderIds(ctx, alice)).length, + ctx.perpsRestingOrderCount, + "test precondition: alice should have a resting perps order at fixture time", + ); + assert.equal( + (await readFuturesOrderIds(ctx, alice)).length, + ctx.futuresRestingOrderCount, + "test precondition: alice should have a resting futures order at fixture time", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + // End state: nothing left on either venue. + await expectPerpsClosed(ctx, alice); + await expectFuturesClosed(ctx, alice); + await expectNoOpenOrders(ctx, alice); + + // The actual ordering invariant. + const perpsOrderBlock = await readPerpsOrderLiquidationBlock(ctx, alice); + const futuresOrderBlock = await readFuturesOrderLiquidationBlock(ctx, alice); + const perpsPositionBlock = await readPerpsPositionLiquidationBlock(ctx, alice); + const futuresPositionBlock = await readFuturesPositionLiquidationBlock(ctx, alice); + assert.ok(perpsOrderBlock !== null, "expected a perps OrderLiquidated event"); + assert.ok(futuresOrderBlock !== null, "expected a futures OrderLiquidated event"); + assert.ok(perpsPositionBlock !== null, "expected a perps PositionLiquidated event"); + assert.ok(futuresPositionBlock !== null, "expected a futures PositionLiquidated event"); + + const latestOrderBlock = max(perpsOrderBlock, futuresOrderBlock); + const earliestPositionBlock = min(perpsPositionBlock, futuresPositionBlock); + assert.ok( + latestOrderBlock <= earliestPositionBlock, + `expected every order liquidation to precede every position liquidation, ` + + `got orders={perps:${perpsOrderBlock}, futures:${futuresOrderBlock}} ` + + `positions={perps:${perpsPositionBlock}, futures:${futuresPositionBlock}}`, + ); + }, + ); }); // ───────────────────────────────────────────────────────────────────────── @@ -300,6 +527,56 @@ describe("PredictiveCoordinator (live oracle events)", () => { await expectPerpsClosed(ctx, alice); }, ); + + it( + "stays silent when the oracle moves but no user threshold is crossed", + { timeout: 30_000 }, + async () => { + // Precondition: alice holds a healthy perps long ($4.21 entry, + // ~$1.82 `liqDown` threshold per the predictor's solver). A 3% + // BTC/USDC tick translates to a ~3% hashprice change — comfortably + // above her liquidation threshold. + // + // Contract: the predictor must observe the `AnswerUpdated` event + // (price feed *does* update) but conclude no user is crossing and + // therefore enqueue nothing. We verify the negative invariant: + // 1. queue stays empty, + // 2. alice's position is untouched, + // 3. her account survives a planner run with `healthy` outcome. + // + // This guards against a regression where every oracle tick would + // wastefully fan out into a full planner sweep. + const ctx = await loadFixture(perpsLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await discoverAndIndex(keeper, alice); + assert.equal(keeper.queue.size(), 0, "precondition: queue empty before move"); + + // 3% downward tick on BTC/USDC. Hashprice is derived from + // BTC/USDC, so we don't need to touch the hashprice oracle directly. + const smallMovedBtc = (ctx.config.initialBtcUsdc * 97n) / 100n; + await ctx.bumpBtcUsdc(smallMovedBtc); + + // Let the predictor's `AnswerUpdated` watcher process the event + // and finish any rebuild. `awaitIdle` blocks on the rebuild queue. + await keeper.predictor.awaitIdle(); + + assert.equal( + keeper.queue.size(), + 0, + "predictor enqueued the user on a sub-threshold move (false positive)", + ); + + // Sanity: the planner agrees alice is still healthy. + const outcome = await keeper.planner.run(alice); + expectHealthy(outcome); + + const position = await readPerpsPosition(ctx, alice); + assert.notEqual(position.netQuantity, 0n, "position should still be open"); + }, + ); }); // ───────────────────────────────────────────────────────────────────────── @@ -328,3 +605,15 @@ describe("Notifier (live HTTP)", () => { }, ); }); + +// ───────────────────────────────────────────────────────────────────────── +// Local utilities +// ───────────────────────────────────────────────────────────────────────── + +function max(a: bigint, b: bigint): bigint { + return a > b ? a : b; +} + +function min(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 3259d30..0dd64ee 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -35,8 +35,17 @@ export interface BaseFixture extends DeployedStack { bumpBtcUsdc(newPrice: bigint): Promise; /** Deposit USDC into the vault from the given (test-known) wallet. */ deposit(userAddr: Address, amount: bigint): Promise; - /** Crash both oracles in lockstep so the predictor reacts via BTC/USDC. */ + /** + * Apply a fresh hashprice and a *paired* BTC/USDC tick. The predictor + * only listens to the BTC/USDC channel, so the second write is what + * makes the event-driven liquidation path observable; the hashprice + * write is what actually moves PnL. + * + * `crashOracles` moves BTC/USDC *down* (long-side loss); `pumpOracles` + * moves it *up* (short-side loss). + */ crashOracles(hashpricePrice: bigint): Promise; + pumpOracles(hashpricePrice: bigint): Promise; } export interface AliceDepositFixture extends BaseFixture { @@ -51,6 +60,28 @@ export interface PerpsLongFixture extends BaseFixture { makeLiquidatable(): Promise; } +/** Alice holds a perps short that is healthy at the entry price. */ +export interface PerpsShortFixture extends BaseFixture { + aliceDeposit: bigint; + /** Positive — the absolute value of alice's short. */ + aliceQty: bigint; + /** Pump hashprice + BTC/USDC so Alice's short becomes liquidatable. */ + makeLiquidatable(): Promise; +} + +/** Two independent users both hold underwater positions after the crash. */ +export interface TwoUnderwaterUsersFixture extends BaseFixture { + /** Deeper-underwater user (closed first by mmSurplus priority). */ + worseDeposit: bigint; + worseQty: bigint; + worseUser: Address; + /** Less-underwater user (closed second). */ + betterDeposit: bigint; + betterQty: bigint; + betterUser: Address; + makeLiquidatable(): Promise; +} + /** Alice holds a perps long *and* a resting buy order that didn't match. */ export interface PerpsOrdersAndPositionFixture extends PerpsLongFixture { /** Count of resting (unmatched) orders Alice has after setup. */ @@ -85,6 +116,16 @@ export interface CrossVenueFixture extends BaseFixture { makeLiquidatable(): Promise; } +/** + * Alice has perps + futures *positions* AND a resting order on each + * venue. The crash makes everything underwater so the planner has to run + * its full two-leg flow (orders across both venues, then positions). + */ +export interface CrossVenueOrdersAndPositionsFixture extends CrossVenueFixture { + perpsRestingOrderCount: number; + futuresRestingOrderCount: number; +} + // ───────────────────────────────────────────────────────────────────────── // Base fixture // ───────────────────────────────────────────────────────────────────────── @@ -96,17 +137,16 @@ export async function baseFixture(rpcUrl: string): Promise { bumpHashprice: (price) => writeOracle(stack, stack.addresses.hashpriceOracle, price), bumpBtcUsdc: (price) => writeOracle(stack, stack.addresses.btcUsdcFeed, price), deposit: (user, amount) => depositTo(stack, user, amount), - /** - * Apply a fresh hashprice and a *paired* BTC/USDC tick. The predictor - * only listens to the BTC/USDC channel, so the second write is what - * makes the event-driven liquidation path observable; the hashprice - * write is what actually moves PnL. - */ crashOracles: async (hashpricePrice) => { await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); const movedBtc = (stack.config.initialBtcUsdc * 9n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, + pumpOracles: async (hashpricePrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + const movedBtc = (stack.config.initialBtcUsdc * 11n) / 10n; + await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); + }, }; } @@ -163,6 +203,91 @@ export function perpsLongCrashFixtureBuilder(rpcUrl: string) { }; } +/** + * Mirror of `perpsLongCrashFixtureBuilder` for short-side coverage. Alice + * sells (negative qty) into Bob's bid; a price *rise* makes her short + * unrealized-loss climb past the deposit. Sizing is identical to the + * long-side case (40 qty, $100 deposit) — symmetry test for the PnL sign + * handling in `PerpsVenue.readPositions`. + * + * - IM at entry: 40 · 0.10 · $4.21 = $16.84 → fits. + * - On price doubling to $8.42: unrealized loss = ($8.42 − $4.21) · 40 = $168.40. + * - Vault $100 < MM (~$168) ⇒ liquidatable. + */ +export function perpsShortCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("100", base.config.tokenDecimals); + const bobDeposit = parseUnits("2000", base.config.tokenDecimals); + const aliceQty = parseUnits("40", base.config.quantityDecimals); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + // Bob bids, alice sells into it. Sign convention: positive qty = buy. + await matchPerpsTrade(base, { + buyer: base.accounts.bob, + seller: base.accounts.alice, + price: base.config.initialHashprice, + quantity: aliceQty, + }); + + return { + ...base, + aliceDeposit, + aliceQty, + makeLiquidatable: () => + base.pumpOracles(parseUnits("8.42", base.config.oracleDecimals)), + }; + }; +} + +/** + * Two independent users (`alice` + `dave`) both go long perps. Alice has + * a larger position so her post-crash `mmSurplus` is more negative than + * dave's — she should be popped from the coordinator queue first. + * + * Bob is the shared counterparty taking the combined short. + */ +export function twoUnderwaterUsersFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + // Both deposits are insufficient to cover the post-crash unrealized + // loss; alice's deficit is bigger so her `mmSurplus` is more negative. + const aliceDeposit = parseUnits("100", base.config.tokenDecimals); + const daveDeposit = parseUnits("30", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const aliceQty = parseUnits("40", base.config.quantityDecimals); // ~$168 loss, $100 cover ⇒ −$68 + const daveQty = parseUnits("20", base.config.quantityDecimals); // ~$84 loss, $30 cover ⇒ −$54 + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.dave.account.address, daveDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + // One bob short covering both — placed first so both takers match it. + await placePerpsOrder( + base, + base.accounts.bob, + base.config.initialHashprice, + -(aliceQty + daveQty), + ); + await placePerpsOrder(base, base.accounts.alice, base.config.initialHashprice, aliceQty); + await placePerpsOrder(base, base.accounts.dave, base.config.initialHashprice, daveQty); + + return { + ...base, + worseDeposit: aliceDeposit, + worseQty: aliceQty, + worseUser: base.accounts.alice.account.address, + betterDeposit: daveDeposit, + betterQty: daveQty, + betterUser: base.accounts.dave.account.address, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + /** * Same as `perpsLongCrashFixtureBuilder` but at entry-time Alice *also* * places a far-away resting buy order that never matched. After the @@ -336,17 +461,90 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { * puts both legs underwater at once, exercising the planner's coordinated * cross-venue ranking. * - * Sized so the perps leg is the worst (largest `unrealizedLoss`) and the - * futures leg is meaningful but secondary — the planner closes the perps - * position first, then loops to clear futures. + * Two parameterised variants are exposed via dedicated builders: + * + * - `crossVenuePerpsDominantFixtureBuilder` — perps `unrealizedLoss` + * dominates futures (ratio ≈ 14:1). The planner should liquidate + * perps first, then futures. + * - `crossVenueFuturesDominantFixtureBuilder` — futures dominates perps + * (ratio ≈ 1:140). The planner should liquidate futures first. + * + * Together they prove the planner ranks by *loss size*, not venue order. + */ +function crossVenueFixtureBody( + base: BaseFixture, + sizing: { aliceDeposit: bigint; bobDeposit: bigint; alicePerpsQty: bigint; aliceFuturesQty: number }, +): Promise { + return (async () => { + const { aliceDeposit, bobDeposit, alicePerpsQty, aliceFuturesQty } = sizing; + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + })(); +} + +/** + * Perps-dominant: alice has a 100-qty perps long ($420 unrealized loss + * after the crash) and a 1-unit futures long ($29.40 loss). The planner + * must liquidate perps first by `unrealizedLoss` ranking. */ -export function crossVenueFixtureBuilder(rpcUrl: string) { +export function crossVenuePerpsDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + return crossVenueFixtureBody(base, { + aliceDeposit: parseUnits("200", base.config.tokenDecimals), + bobDeposit: parseUnits("5000", base.config.tokenDecimals), + alicePerpsQty: parseUnits("100", base.config.quantityDecimals), + aliceFuturesQty: 1, + }); + }; +} + +/** + * Cross-venue with resting orders on *both* venues. Alice has matched + * positions (perps long + futures long) plus a stale far-out-of-market + * resting buy order on each book. The crash makes everything underwater. + * + * The keeper must: + * 1. Cancel the resting perps order (orders-leg, perps venue) + * 2. Cancel the resting futures order (orders-leg, futures venue) + * 3. Close the perps position (position-leg, worst-first) + * 4. Close the futures position (position-leg, next-worst) + * + * Steps 1–2 must strictly precede 3–4: the planner walks every venue's + * orders-leg before touching any position. The test verifies this by + * comparing block numbers of `OrderLiquidated` vs `PositionLiquidated` + * events on each venue. + */ +export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("250", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); - const alicePerpsQty = parseUnits("100", base.config.quantityDecimals); - const aliceFuturesQty = 1; + const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); + const aliceFuturesQty = 6; await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); @@ -365,17 +563,57 @@ export function crossVenueFixtureBuilder(rpcUrl: string) { quantity: aliceFuturesQty, }); + // Stale buys well below current marks — no counterparty exists at + // these levels so each order rests on its book. + const restingPrice = parseUnits("1.00", base.config.oracleDecimals); + await placePerpsOrder( + base, + base.accounts.alice, + restingPrice, + parseUnits("5", base.config.quantityDecimals), + ); + await placeFuturesOrder( + base, + base.accounts.alice, + parseUnits("2.00", base.config.oracleDecimals), + base.config.futuresFirstDeliveryDate, + 1, + ); + return { ...base, aliceDeposit, alicePerpsQty, aliceFuturesQty, + perpsRestingOrderCount: 1, + futuresRestingOrderCount: 1, makeLiquidatable: () => base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), }; }; } +/** + * Futures-dominant: alice has a 1-qty perps long ($4.20 unrealized loss) + * and a 12-unit futures long ($352.80 loss over the 7-day delivery + * window). The planner must liquidate futures first. + * + * The futures qty is capped at 12 because `createOrder` loops once per + * contract in the matching engine; larger values blow past Hardhat's + * per-tx gas cap (16M). + */ +export function crossVenueFuturesDominantFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + return crossVenueFixtureBody(base, { + aliceDeposit: parseUnits("300", base.config.tokenDecimals), + bobDeposit: parseUnits("5000", base.config.tokenDecimals), + alicePerpsQty: parseUnits("1", base.config.quantityDecimals), + aliceFuturesQty: 12, + }); + }; +} + // ───────────────────────────────────────────────────────────────────────── // Internal placement / writing helpers // ───────────────────────────────────────────────────────────────────────── From c967e166e5367ac2177b8c839fb916ee330940f5 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 25 May 2026 17:16:36 +0500 Subject: [PATCH 023/180] market-maker: flatten viem error chains in serialization Rewrite errSerializer.ts to walk the cause chain once and emit a flat, minimal payload instead of preserving the full nested structure. - Drop pino.stdSerializers.errWithCause (preserved full cause chain with duplicated multicall calldata hex at every nesting level) - Walk cause chain to harvest errorName, data (trimmed hex), and frame-only stack - Emit only: name, message (preferring viem shortMessage), errorName, data, stack frames, contractAddress, functionName, sender, tenderlyUrl - No cause, type, details, metaMessages, version, args, or docsPath Also adds tenderly module for surfacing Tenderly simulation URLs. Updates tests to verify flattened output behavior. --- market-maker/pnpm-lock.yaml | 18 +-- market-maker/src/adapters/futures/events.ts | 8 +- .../src/adapters/futures/instrument.ts | 5 +- .../src/adapters/futures/ownOrders.ts | 2 +- market-maker/src/adapters/futures/venue.ts | 86 +++++++++++--- market-maker/src/adapters/perps/venue.ts | 36 ++++-- market-maker/src/core/errSerializer.ts | 111 ++++++++++++++---- market-maker/src/core/tenderly.ts | 52 ++++++++ market-maker/tests/core/errSerializer.test.ts | 95 +++++++++++---- market-maker/tests/core/tenderly.test.ts | 88 ++++++++++++++ 10 files changed, 416 insertions(+), 85 deletions(-) create mode 100644 market-maker/src/core/tenderly.ts create mode 100644 market-maker/tests/core/tenderly.test.ts diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index a52021e..a7e2737 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#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 @@ -1195,8 +1195,8 @@ packages: version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79} version: 1.0.0 color-convert@2.0.1: @@ -1496,8 +1496,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474} version: 1.0.0 engines: {node: 24.x} @@ -4395,7 +4395,7 @@ snapshots: - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: {} color-convert@2.0.1: dependencies: @@ -4485,7 +4485,7 @@ snapshots: '@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/b8045fafcf7178928d414269e6d86d797b2e2cdc + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 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' solidity-linked-list: 6.5.0 @@ -4761,7 +4761,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/fd51b4e07c3c11b728ab0d8f00efc38aecb2bd5a#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#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 @@ -4770,7 +4770,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 82e07a1..4ce2d01 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -1,6 +1,6 @@ import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures"; export const FUTURES_INSTRUMENT_ID = "futures"; @@ -83,7 +83,11 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { }; } case "PositionClosed": - return { type: "position-changed", participant: "0x0" as `0x${string}`, instrumentId: FUTURES_INSTRUMENT_ID }; + return { + type: "position-changed", + participant: "0x0" as `0x${string}`, + instrumentId: FUTURES_INSTRUMENT_ID, + }; default: return null; } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 46179d5..7574de3 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -11,7 +11,7 @@ import type { OrderIntent, Position, } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; @@ -126,7 +126,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { if (this.deliveryDurationDaysCache === null) return 0n; // marginPct is loaded lazily at first canPlace call; if we don't have it // yet, return 0 and let the engine gate sort it out on the first tx. - const cachedMarginPct = (this.venue as unknown as { marginPercentCache?: bigint }).marginPercentCache; + const cachedMarginPct = (this.venue as unknown as { marginPercentCache?: bigint }) + .marginPercentCache; if (!cachedMarginPct) return 0n; return (intent.price * this.deliveryDurationDaysCache * intent.size * cachedMarginPct) / 100n; } diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 140e978..daee43b 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -1,6 +1,6 @@ import type pino from "pino"; import type { OwnOrder, OwnOrderEvent, OwnOrderSource, Unsubscribe } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FUTURES_INSTRUMENT_ID } from "./events.ts"; diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 6e3113e..4c9885b 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -1,4 +1,4 @@ -import { erc20Abi } from "viem"; +import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { @@ -10,12 +10,13 @@ import type { WalletContext, } from "../../core/adapter.ts"; import type { NetworkClients } from "../../core/client.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures.ts"; +import { FuturesAbi } from "futures-contracts/abi/Futures"; 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"; import { depositToVault } from "../../core/vaultDeposit.ts"; import { RawOracleReader } from "../../core/rawOracle.ts"; +import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; import { FuturesVenueEvents } from "./events.ts"; @@ -70,7 +71,9 @@ export class FuturesVenueAdapter implements VenueAdapter { this.address = opts.address; this.logger = opts.logger.child({ component: "futures-venue" }); - const mc3 = opts.multicall3Address ?? (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); + const mc3 = + opts.multicall3Address ?? + (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; @@ -103,21 +106,43 @@ export class FuturesVenueAdapter implements VenueAdapter { return this.instrumentSingleton; } - async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { - 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, - }); + async multicall( + calls: `0x${string}`[], + opts: { maxFeePerGas?: bigint } = {}, + ): 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, + }); + } catch (err) { + // Attach a Tenderly simulation URL so the failed multicall can be + // replayed/debugged with one click from the log. + 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<{ vault: `0x${string}`; engine: `0x${string}`; token: `0x${string}` }> { + async resolveAddresses(): Promise<{ + vault: `0x${string}`; + engine: `0x${string}`; + token: `0x${string}`; + }> { if (this.vaultAddressCache && this.engineAddressCache && this.collateralTokenCache) { return { vault: this.vaultAddressCache, @@ -183,7 +208,10 @@ export class FuturesVenueAdapter implements VenueAdapter { // The on-chain check is the real authority; this is just our pre-trade gate. this.deliveryDurationDaysCache = BigInt(duration); this.marginPercentCache = BigInt(liqMarginPct); - return { deliveryDurationDays: this.deliveryDurationDaysCache, marginPct: this.marginPercentCache }; + return { + deliveryDurationDays: this.deliveryDurationDaysCache, + marginPct: this.marginPercentCache, + }; } } @@ -217,10 +245,30 @@ class FuturesCollateralAccount implements CollateralAccount { allowFailure: false, contracts: [ { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, - { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, - { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, - { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesOrderMargin", args: [owner] }, - { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesUnrealizedPnl", args: [owner] }, + { + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioIM", + args: [owner], + }, + { + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioMM", + args: [owner], + }, + { + address: this.venue.address, + abi: FuturesAbi, + functionName: "getFuturesOrderMargin", + args: [owner], + }, + { + address: this.venue.address, + abi: FuturesAbi, + functionName: "getFuturesUnrealizedPnl", + args: [owner], + }, { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, ], diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 8f1cb3c..dfef667 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -1,4 +1,4 @@ -import { erc20Abi } from "viem"; +import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { @@ -16,6 +16,7 @@ import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/Portfo import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; +import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { PerpsInstrumentAdapter } from "./instrument.ts"; import { PerpsVenueEvents } from "./events.ts"; @@ -112,15 +113,30 @@ export class PerpsVenueAdapter implements VenueAdapter { } async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { - 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, - }); + 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, + }); + } catch (err) { + // Attach a Tenderly simulation URL so the failed multicall can be + // replayed/debugged with one click from the log. + 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 ───────── diff --git a/market-maker/src/core/errSerializer.ts b/market-maker/src/core/errSerializer.ts index fe0471c..e5f9c0e 100644 --- a/market-maker/src/core/errSerializer.ts +++ b/market-maker/src/core/errSerializer.ts @@ -1,33 +1,102 @@ -import pino from "pino"; import type { ErrorInfo } from "./errors.ts"; -function stripAbiRecursive(value: T): T { - if (value === null || typeof value !== "object") { - return value; - } - if (Array.isArray(value)) { - return value.map((item) => stripAbiRecursive(item)) as T; - } - const obj = value as Record; - const out: Record = {}; - for (const key of Object.keys(obj)) { - if (key === "abi") continue; - out[key] = stripAbiRecursive(obj[key]); +/** + * viem errors nest 4-5 cause levels deep, and every level re-stringifies the + * full multicall calldata into its `message`, `stack`, and `metaMessages`. + * Naively serializing with `pino.stdSerializers.errWithCause` produces tens + * of KB of duplicated hex per failed call. + * + * This serializer instead walks the cause chain once and emits a flat, + * minimal payload: `name`, `message` (preferring viem's `shortMessage`), the + * decoded custom error (`errorName`, e.g. `"FailedCall"`), a trimmed `data` + * hex selector/blob, and a single frames-only `stack` from the top error. + * The cause chain is harvested for `errorName`/`data` but not emitted — + * viem's cause levels are just re-wrappings of the same revert. + */ + +const MAX_DATA_LEN = 200; + +function isObj(v: unknown): v is Record { + return v !== null && typeof v === "object"; +} + +function* walkCauses(err: unknown): Generator> { + const seen = new Set(); + let cur: unknown = err; + while (isObj(cur) && !seen.has(cur)) { + seen.add(cur); + yield cur; + cur = (cur as Record).cause; } - return out as T; } -/** - * Serializes an error for logs and API: errWithCause (includes cause) then - * strips `abi` recursively at every depth. Pino redact cannot match arbitrary - * depth (each `*` is one level only), so we strip in the serializer instead. - */ +function pickString(o: Record, k: string): string | undefined { + const v = o[k]; + return typeof v === "string" ? v : undefined; +} + +function firstLine(s: string): string { + const idx = s.indexOf("\n"); + return idx === -1 ? s : s.slice(0, idx); +} + +function shortMessageOf(lvl: Record): string | undefined { + const sm = pickString(lvl, "shortMessage"); + if (sm) return sm; + const m = pickString(lvl, "message"); + return m === undefined ? undefined : firstLine(m); +} + +function stackFrames(stack: unknown): string { + if (typeof stack !== "string") return ""; + return stack + .split("\n") + .filter((l) => /^\s*at /.test(l)) + .join("\n"); +} + +function trimHex(s: string): string { + return s.length <= MAX_DATA_LEN ? s : `${s.slice(0, MAX_DATA_LEN)}…<+${s.length - MAX_DATA_LEN} chars>`; +} + export function serializeError(err: unknown): Record { if (err === null || typeof err !== "object" || !(err instanceof Error)) { return { raw: err }; } - const serialized = pino.stdSerializers.errWithCause(err) as Record; - return stripAbiRecursive(serialized) as Record; + + const chain = [...walkCauses(err)]; + const top = chain[0] ?? {}; + + let errorName: string | undefined; + let data: string | undefined; + for (const lvl of chain) { + if (errorName === undefined && isObj(lvl.data)) { + errorName = pickString(lvl.data as Record, "errorName"); + } + if (data === undefined && typeof lvl.data === "string") { + data = trimHex(lvl.data); + } + if (errorName !== undefined && data !== undefined) break; + } + + let stack = ""; + for (const lvl of chain) { + stack = stackFrames(lvl.stack); + if (stack) break; + } + + const name = pickString(top, "name") ?? err.name ?? "Error"; + const message = shortMessageOf(top) ?? "(no message)"; + + const out: Record = { name, message }; + if (errorName) out.errorName = errorName; + if (data !== undefined) out.data = data; + if (stack) out.stack = stack; + for (const k of ["contractAddress", "functionName", "sender", "tenderlyUrl"] as const) { + const v = pickString(top, k); + if (v) out[k] = v; + } + return out; } export function toErrorInfo(err: unknown): ErrorInfo { diff --git a/market-maker/src/core/tenderly.ts b/market-maker/src/core/tenderly.ts new file mode 100644 index 0000000..3d94201 --- /dev/null +++ b/market-maker/src/core/tenderly.ts @@ -0,0 +1,52 @@ +/** + * Builds a Tenderly "new simulation" URL that pre-fills the failed call so a + * dev can replay/debug it with one click. We attach this to write-call errors + * at the venue layer (see `FuturesVenueAdapter.multicall`) so the serialized + * error in logs includes a `tenderlyUrl` field. + * + * Supported query params on `dashboard.tenderly.co/simulator/new`: + * network — chain ID + * contractAddress — `to` + * from — `from` + * rawFunctionInput — full calldata + * value — wei (optional, omitted when zero) + * gas — gas limit (optional) + */ +export interface TenderlySimulationInput { + chainId: number; + from: `0x${string}`; + to: `0x${string}`; + data: `0x${string}`; + value?: bigint; + gas?: bigint; +} + +const TENDERLY_BASE = "https://dashboard.tenderly.co/simulator/new"; + +export function buildTenderlySimulationUrl(input: TenderlySimulationInput): string { + const params = new URLSearchParams({ + network: String(input.chainId), + contractAddress: input.to, + from: input.from, + rawFunctionInput: input.data, + }); + if (input.value !== undefined && input.value !== 0n) { + params.set("value", input.value.toString()); + } + if (input.gas !== undefined) { + params.set("gas", input.gas.toString()); + } + return `${TENDERLY_BASE}?${params.toString()}`; +} + +/** + * Attaches `tenderlyUrl` to an error (mutating it) so the standard error + * serializer surfaces it in logs and `/health`. Returns the same error for + * convenient `throw attachTenderlyUrl(err, ...)` usage. + */ +export function attachTenderlyUrl(err: unknown, input: TenderlySimulationInput): unknown { + if (err !== null && typeof err === "object") { + (err as Record).tenderlyUrl = buildTenderlySimulationUrl(input); + } + return err; +} diff --git a/market-maker/tests/core/errSerializer.test.ts b/market-maker/tests/core/errSerializer.test.ts index 069e8f1..58c6ebb 100644 --- a/market-maker/tests/core/errSerializer.test.ts +++ b/market-maker/tests/core/errSerializer.test.ts @@ -3,46 +3,98 @@ import assert from "node:assert/strict"; import { serializeError, toErrorInfo } from "../../src/core/errSerializer.ts"; describe("serializeError", () => { - it("preserves message and stack of a simple Error", () => { + it("flattens a simple Error to name, message, and frame-only stack", () => { const err = new Error("short"); err.stack = "Error: short\n at fn (test.ts:1:1)"; const out = serializeError(err); + assert.equal(out.name, "Error"); assert.equal(out.message, "short"); - assert.equal(out.stack, err.stack); + assert.equal(out.stack, " at fn (test.ts:1:1)"); }); - it("preserves long messages without truncation", () => { - const msg = "x".repeat(200); - const err = new Error(msg); + it("uses only the first line of a multi-line message", () => { + const err = new Error("first\nthen lots of viem contract call dump"); const out = serializeError(err); - assert.equal(out.message, msg); + assert.equal(out.message, "first"); }); - it("preserves full stack without truncation", () => { + it("prefers viem `shortMessage` over `message`", () => { + const err = new Error("very long viem message with newlines\nand calldata"); + Object.assign(err, { shortMessage: "Tx reverted." }); + const out = serializeError(err); + assert.equal(out.message, "Tx reverted."); + }); + + it("strips the message preamble from `stack`, keeping only `at` frames", () => { const err = new Error("boom"); - err.stack = "Error: boom\n" + " at fn (file.ts:1:1)\n".repeat(10); + err.stack = + "Error: boom\n junk header\n at fn (file.ts:1:1)\n at g (file.ts:2:2)"; const out = serializeError(err); - assert.equal(out.stack, err.stack); + assert.equal(out.stack, " at fn (file.ts:1:1)\n at g (file.ts:2:2)"); }); - it("includes cause via errWithCause", () => { + it("does not emit the cause chain (viem cause levels are noisy re-wrappings)", () => { const inner = new Error("root cause"); const outer = new Error("wrapper", { cause: inner }); const out = serializeError(outer); - const cause = out.cause as Record; - assert.equal(cause.message, "root cause"); - assert.ok(cause.stack); + assert.equal(out.causes, undefined); + assert.equal(out.cause, undefined); + assert.equal(out.message, "wrapper"); + }); + + it("surfaces `errorName` from any level's `data.errorName` (viem custom error)", () => { + const inner = Object.assign(new Error("inner"), { + data: { errorName: "FailedCall" }, + }); + const outer = new Error("outer", { cause: inner }); + const out = serializeError(outer); + assert.equal(out.errorName, "FailedCall"); + }); + + it("surfaces a trimmed hex `data` from the chain", () => { + const huge = `0x${"a".repeat(2000)}`; + const inner = Object.assign(new Error("rpc"), { data: huge }); + const outer = new Error("outer", { cause: inner }); + const out = serializeError(outer); + const data = out.data as string; + assert.ok(data.length < huge.length); + assert.match(data, /…<\+\d+ chars>$/); }); - it("strips an `abi` field at any depth", () => { - const err = new Error("contract"); - (err as unknown as Record).abi = [{ name: "fake" }]; - (err as unknown as Record).inner = { abi: [{ name: "deep" }], ok: 1 }; + it("leaves a short `data` selector untouched", () => { + const err = Object.assign(new Error("e"), { data: "0xd6bda275" }); const out = serializeError(err); - assert.equal(out.abi, undefined); - const inner = out.inner as Record; - assert.equal(inner.abi, undefined); - assert.equal(inner.ok, 1); + assert.equal(out.data, "0xd6bda275"); + }); + + it("preserves viem call-site fields when present", () => { + const err = Object.assign(new Error("contract reverted"), { + shortMessage: "reverted", + contractAddress: "0xabc", + functionName: "multicall", + sender: "0xdef", + }); + const out = serializeError(err); + assert.equal(out.contractAddress, "0xabc"); + assert.equal(out.functionName, "multicall"); + assert.equal(out.sender, "0xdef"); + }); + + it("surfaces a `tenderlyUrl` attached at the venue layer", () => { + const err = Object.assign(new Error("reverted"), { + tenderlyUrl: "https://dashboard.tenderly.co/simulator/new?network=84532", + }); + const out = serializeError(err); + assert.equal(out.tenderlyUrl, "https://dashboard.tenderly.co/simulator/new?network=84532"); + }); + + it("walks a cyclic cause chain without looping (harvest only)", () => { + const a = Object.assign(new Error("a"), { data: "0xaaaa" }); + const b = new Error("b", { cause: a }); + Object.assign(a, { cause: b }); + const out = serializeError(b); + assert.equal(out.message, "b"); + assert.equal(out.data, "0xaaaa"); }); it("handles non-Error and null gracefully", () => { @@ -55,6 +107,7 @@ describe("toErrorInfo", () => { it("wraps non-Error as { message }", () => { assert.deepEqual(toErrorInfo("plain"), { message: "plain" }); }); + it("delegates to serializeError for Errors", () => { const out = toErrorInfo(new Error("boom")); assert.equal(out.message, "boom"); diff --git a/market-maker/tests/core/tenderly.test.ts b/market-maker/tests/core/tenderly.test.ts new file mode 100644 index 0000000..24d8792 --- /dev/null +++ b/market-maker/tests/core/tenderly.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { attachTenderlyUrl, buildTenderlySimulationUrl } from "../../src/core/tenderly.ts"; + +describe("buildTenderlySimulationUrl", () => { + it("encodes the required fields onto the simulator URL", () => { + const url = buildTenderlySimulationUrl({ + chainId: 84532, + from: "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4", + to: "0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479", + data: "0xac9650d8", + }); + const parsed = new URL(url); + assert.equal(parsed.origin + parsed.pathname, "https://dashboard.tenderly.co/simulator/new"); + assert.equal(parsed.searchParams.get("network"), "84532"); + assert.equal(parsed.searchParams.get("from"), "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4"); + assert.equal( + parsed.searchParams.get("contractAddress"), + "0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479", + ); + assert.equal(parsed.searchParams.get("rawFunctionInput"), "0xac9650d8"); + assert.equal(parsed.searchParams.get("value"), null); + assert.equal(parsed.searchParams.get("gas"), null); + }); + + it("omits `value` when zero and includes it when non-zero", () => { + const zero = new URL( + buildTenderlySimulationUrl({ + chainId: 1, + from: "0xfrom", + to: "0xto", + data: "0x", + value: 0n, + }), + ); + assert.equal(zero.searchParams.get("value"), null); + const nonZero = new URL( + buildTenderlySimulationUrl({ + chainId: 1, + from: "0xfrom", + to: "0xto", + data: "0x", + value: 1_000_000_000n, + }), + ); + assert.equal(nonZero.searchParams.get("value"), "1000000000"); + }); + + it("includes `gas` when provided", () => { + const parsed = new URL( + buildTenderlySimulationUrl({ + chainId: 1, + from: "0xfrom", + to: "0xto", + data: "0x", + gas: 500_000n, + }), + ); + assert.equal(parsed.searchParams.get("gas"), "500000"); + }); +}); + +describe("attachTenderlyUrl", () => { + it("mutates the error to add `tenderlyUrl` and returns it", () => { + const err = new Error("boom"); + const out = attachTenderlyUrl(err, { + chainId: 84532, + from: "0xfrom", + to: "0xto", + data: "0xdead", + }); + assert.equal(out, err); + const url = (err as unknown as { tenderlyUrl: string }).tenderlyUrl; + assert.match(url, /^https:\/\/dashboard\.tenderly\.co\/simulator\/new\?/); + assert.match(url, /network=84532/); + assert.match(url, /rawFunctionInput=0xdead/); + }); + + it("is a no-op for non-object errors", () => { + const out = attachTenderlyUrl("string error", { + chainId: 1, + from: "0xfrom", + to: "0xto", + data: "0x", + }); + assert.equal(out, "string error"); + }); +}); From 1e914f90aa140be6a9de0d78cfbe813117cfee16 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 25 May 2026 17:16:55 +0500 Subject: [PATCH 024/180] keeper: add error serializer to pino config, delivery coordinator, and runtime modules - Configure pino with serializers: { err: serializeError } so all logger.error/warn({ err }, ...) calls produce flat error payloads instead of the full nested viem cause chain with duplicated hex - Add delivery coordinator for futures position cash-settlement - Add BalanceMonitor for keeper signer gas balance tracking - Add OutdatedOrderSweeper for cleaning stale futures orders - Add gas cost tracking (GasCost, unstick) - Add ETH/USD price feed for USD-enriched tx logs - Add predictive coordinator for pre-computed liquidation thresholds - Update venues, healthcheck, and integration tests --- keeper/package.json | 4 +- keeper/scripts/audit-indexer-sync.ts | 135 ++ keeper/scripts/debug-delivery-bootstrap.ts | 112 ++ keeper/scripts/diff-indexer-vs-chain.sh | 72 ++ keeper/src/chain.ts | 19 +- keeper/src/config.ts | 212 ++- keeper/src/delivery/coordinator.ts | 1142 +++++++++++++++++ keeper/src/index.ts | 171 ++- keeper/src/oracle/ethUsdFeed.ts | 144 +++ keeper/src/predict/coordinator.ts | 67 + keeper/src/predict/predictiveIndex.ts | 5 + keeper/src/runtime/balanceMonitor.ts | 118 ++ keeper/src/runtime/healthcheck.ts | 126 +- keeper/src/runtime/outdatedOrderSweeper.ts | 368 ++++++ keeper/src/tx/gasCost.ts | 68 + keeper/src/tx/liquidate.ts | 26 +- keeper/src/tx/unstick.ts | 239 ++++ keeper/src/venues/futures.ts | 11 +- keeper/src/venues/perps.ts | 11 +- keeper/tests/delivery/coordinator.test.ts | 1056 +++++++++++++++ keeper/tests/integration/buildKeeper.ts | 64 + keeper/tests/integration/deployStack.ts | 30 +- keeper/tests/integration/helpers.ts | 24 + .../integration/keeper.integration.test.ts | 279 ++++ keeper/tests/oracle/ethUsdFeed.test.ts | 181 +++ keeper/tests/runtime/balanceMonitor.test.ts | 156 +++ keeper/tests/runtime/healthcheck.test.ts | 271 +++- .../runtime/outdatedOrderSweeper.test.ts | 524 ++++++++ keeper/tests/tx/gasCost.test.ts | 79 ++ keeper/tests/tx/unstick.test.ts | 280 ++++ keeper/tests/venues/perps.test.ts | 22 +- 31 files changed, 5908 insertions(+), 108 deletions(-) create mode 100644 keeper/scripts/audit-indexer-sync.ts create mode 100644 keeper/scripts/debug-delivery-bootstrap.ts create mode 100644 keeper/scripts/diff-indexer-vs-chain.sh create mode 100644 keeper/src/delivery/coordinator.ts create mode 100644 keeper/src/oracle/ethUsdFeed.ts create mode 100644 keeper/src/runtime/balanceMonitor.ts create mode 100644 keeper/src/runtime/outdatedOrderSweeper.ts create mode 100644 keeper/src/tx/gasCost.ts create mode 100644 keeper/src/tx/unstick.ts create mode 100644 keeper/tests/delivery/coordinator.test.ts create mode 100644 keeper/tests/oracle/ethUsdFeed.test.ts create mode 100644 keeper/tests/runtime/balanceMonitor.test.ts create mode 100644 keeper/tests/runtime/outdatedOrderSweeper.test.ts create mode 100644 keeper/tests/tx/gasCost.test.ts create mode 100644 keeper/tests/tx/unstick.test.ts diff --git a/keeper/package.json b/keeper/package.json index 7fe4bc7..0fe2a12 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -9,10 +9,10 @@ "scripts": { "postinstall": "node ./scripts/fix-placeholder-manifests.ts", "node": "node --import=amaro/strip", - "dev": "pnpm node --env-file=.env src/index.ts | pino-pretty", + "dev": "pnpm node --env-file=../.env src/index.ts | pino-pretty", "dev:dry": "DRY_RUN=true pnpm node --env-file=.env src/index.ts | pino-pretty", "start": "pnpm node --env-file=.env src/index.ts", - "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/*.test.ts' 'tests/alert/**/*.test.ts' 'tests/coordinator/**/*.test.ts' 'tests/discovery/**/*.test.ts' 'tests/oracle/**/*.test.ts' 'tests/pme/**/*.test.ts' 'tests/predict/**/*.test.ts' 'tests/runtime/**/*.test.ts' 'tests/tx/**/*.test.ts' 'tests/venues/**/*.test.ts'", + "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/*.test.ts' 'tests/alert/**/*.test.ts' 'tests/coordinator/**/*.test.ts' 'tests/delivery/**/*.test.ts' 'tests/discovery/**/*.test.ts' 'tests/oracle/**/*.test.ts' 'tests/pme/**/*.test.ts' 'tests/predict/**/*.test.ts' 'tests/runtime/**/*.test.ts' 'tests/tx/**/*.test.ts' 'tests/venues/**/*.test.ts'", "test:watch": "pnpm node --test --watch --test-concurrency=1 'tests/**/*.test.ts'", "pretest:integration": "pnpm node ./scripts/compile-siblings.ts", "test:integration": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/integration/**/*.test.ts'", diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts new file mode 100644 index 0000000..15d95d4 --- /dev/null +++ b/keeper/scripts/audit-indexer-sync.ts @@ -0,0 +1,135 @@ +/** + * Audit indexer netQuantityAfter against on-chain getPositionIds count. + * Finds the first block where indexer and chain diverge. + * + * Run: + * pnpm node --env-file=../.env --experimental-strip-types scripts/audit-indexer-sync.ts + */ +import { createPublicClient, http, type Address, type Hex } from "viem"; +import { baseSepolia } from "viem/chains"; +import { FuturesAbi } from "futures-marketplace/Futures.ts"; + +const ENDPOINT = "https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-futures/dev-latest/gn"; +const USER = "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4".toLowerCase(); +const FUT = (process.env.FUTURES_ADDRESS ?? "0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479") as Address; +const RPC = process.env.ETH_NODE_ADDRESS ?? `https://base-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`; + +if (!RPC) throw new Error("Need RPC URL via ETH_NODE_ADDRESS or ALCHEMY_API_KEY"); + +const client = createPublicClient({ chain: baseSepolia, transport: http(RPC) }); + +interface TradeFill { + user: { id: string }; + counterparty: { id: string }; + fillQuantity: number; + netQuantityAfter: number; +} + +interface Trade { + id: string; + tradeQuantity: number; + netQuantityAfter: number; + deliveryAt: string; + transactionHash: string; + blockNumber: string; + fills: TradeFill[]; +} + +async function fetchTrades(): Promise { + const query = ` + query($user: String!) { + trades( + where: { fills_: { user: $user } } + orderBy: blockNumber + orderDirection: asc + ) { + id + tradeQuantity + netQuantityAfter + deliveryAt + transactionHash + blockNumber + fills(where: { user: $user }) { + user { id } + counterparty { id } + fillQuantity + netQuantityAfter + } + } + } + `; + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { user: USER } }), + }); + const json = await res.json(); + if (json.errors) throw new Error(JSON.stringify(json.errors)); + return json.data.trades as Trade[]; +} + +async function getChainPositionCount(blockNumber: number): Promise { + const ids = await client.readContract({ + address: FUT, + abi: FuturesAbi, + functionName: "getPositionIds", + args: [USER as Address], + blockNumber: BigInt(blockNumber), + }); + return (ids as readonly Hex[]).length; +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function main() { + console.log("Fetching trades from indexer..."); + const trades = await fetchTrades(); + console.log(`Found ${trades.length} trades\n`); + + console.log("%-12s %-10s %-10s %-10s %-10s %s", "block", "indexer", "chainLen", "match", "tx", "status"); + console.log("%-12s %-10s %-10s %-10s %-10s %s", "-----", "-------", "--------", "-----", "--", "------"); + + let firstMismatch: { block: number; indexer: number; chain: number; tx: string } | undefined; + + for (const t of trades) { + const block = parseInt(t.blockNumber, 10); + const indexerAbs = Math.abs(t.netQuantityAfter); + + // Rate-limit ourselves + await sleep(150); + + let chainLen: number; + try { + chainLen = await getChainPositionCount(block); + } catch (err) { + console.log("%-12s %-10s %-10s %-10s %-10s %s", block, indexerAbs, "ERR", "-", t.transactionHash.slice(0, 10), "rpc-error"); + continue; + } + + const match = indexerAbs === chainLen ? "✓" : "✗ MISMATCH"; + const status = indexerAbs === chainLen ? "ok" : "MISMATCH"; + + console.log("%-12d %-10d %-10d %-10s %-10s %s", block, indexerAbs, chainLen, indexerAbs === chainLen ? "yes" : "NO", t.transactionHash.slice(0, 10) + "...", status); + + if (indexerAbs !== chainLen && !firstMismatch) { + firstMismatch = { block, indexer: indexerAbs, chain: chainLen, tx: t.transactionHash }; + } + } + + console.log("\n"); + if (firstMismatch) { + console.log("First divergence at block %d:", firstMismatch.block); + console.log(" tx: %s", firstMismatch.tx); + console.log(" indexer netQuantityAfter (abs): %d", firstMismatch.indexer); + console.log(" chain getPositionIds().length: %d", firstMismatch.chain); + } else { + console.log("No divergence detected — indexer and chain are in sync."); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts new file mode 100644 index 0000000..0b5c3f6 --- /dev/null +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -0,0 +1,112 @@ +/** + * One-off diagnostic: replicate `DeliveryCoordinator.bootstrapFromUsers` + * against the live RPC for a hard-coded user list, printing exactly what + * `multicall` returns at each stage. Lets us tell whether discovery is + * silently no-oping (returns []) vs throwing (caught somewhere) vs + * returning data we then fail to index. + * + * Run with: + * pnpm node --env-file=../.env scripts/debug-delivery-bootstrap.ts + */ +import { createPublicClient, http, type Address, type Hex } from "viem"; +import { baseSepolia, base, hardhat } from "viem/chains"; +import { FuturesAbi } from "futures-marketplace/Futures.ts"; + +const FUTURES = process.env.FUTURES_ADDRESS as Address; +const NETWORK = process.env.NETWORK ?? "base-sepolia"; +const ALCHEMY = process.env.ALCHEMY_API_KEY; +if (FUTURES === undefined || ALCHEMY === undefined) { + throw new Error("FUTURES_ADDRESS and ALCHEMY_API_KEY must be set in env"); +} + +const RPC_URL = `https://${NETWORK}.g.alchemy.com/v2/${ALCHEMY}`; +const CHAINS = { "base-sepolia": baseSepolia, "base-mainnet": base, hardhat }; +const chain = CHAINS[NETWORK as keyof typeof CHAINS]; + +// Hard-coded list mirroring the production tracker.list() output. +// Edit if you want to test different users. +const USERS: Address[] = [ + "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4", +]; + +const client = createPublicClient({ chain, transport: http(RPC_URL) }); + +console.log("RPC:", RPC_URL.replace(ALCHEMY, "***")); +console.log("FUTURES:", FUTURES); +console.log("USERS:", USERS); +console.log("multicall3 configured:", chain.contracts?.multicall3?.address); + +console.log("\n--- Stage 1: getPositionIds via multicall ---"); +const idLists = await client.multicall({ + contracts: USERS.map((u) => ({ + address: FUTURES, + abi: FuturesAbi, + functionName: "getPositionIds" as const, + args: [u] as const, + })), + allowFailure: false, +}); +console.log("results:"); +for (let i = 0; i < USERS.length; i++) { + const ids = idLists[i] as readonly Hex[]; + console.log(` ${USERS[i]} → ${ids.length} positions`); + for (const id of ids) console.log(` ${id}`); +} + +const allIds: Hex[] = []; +for (const ids of idLists as readonly (readonly Hex[])[]) allIds.push(...ids); +if (allIds.length === 0) { + console.log("\nNo positions found — bootstrap would return early."); + process.exit(0); +} + +console.log(`\n--- Stage 2: getPositionById for ${allIds.length} ids ---`); +const positions = await client.multicall({ + contracts: allIds.map((id) => ({ + address: FUTURES, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + allowFailure: false, +}); + +const now = BigInt(Math.floor(Date.now() / 1000)); +const block = await client.getBlock(); +console.log("wall-clock now:", now, " block.timestamp:", block.timestamp); + +const deliveryDurationDays = (await client.readContract({ + address: FUTURES, + abi: FuturesAbi, + functionName: "deliveryDurationDays", +})) as number; +const window = BigInt(deliveryDurationDays) * 86_400n; +console.log("deliveryDurationDays:", deliveryDurationDays, "→ window:", window, "s"); + +let live = 0; +let pastDue = 0; +let expired = 0; +for (let i = 0; i < allIds.length; i++) { + const id = allIds[i] as Hex; + const pos = positions[i] as { + seller: Address; + buyer: Address; + deliveryAt: bigint; + }; + const closed = pos.seller === "0x0000000000000000000000000000000000000000"; + if (closed) { + console.log(` ${id} CLOSED (seller==0)`); + continue; + } + live++; + const due = block.timestamp >= pos.deliveryAt; + const dead = block.timestamp > pos.deliveryAt + window; + if (dead) expired++; + else if (due) pastDue++; + console.log( + ` ${id} seller=${pos.seller} buyer=${pos.buyer} deliveryAt=${pos.deliveryAt} ${ + dead ? "EXPIRED-WINDOW" : due ? "PAST-DUE" : "FUTURE" + }`, + ); +} +console.log(`\nsummary: ${live} live, ${pastDue} past-due (settleable), ${expired} expired-window`); diff --git a/keeper/scripts/diff-indexer-vs-chain.sh b/keeper/scripts/diff-indexer-vs-chain.sh new file mode 100644 index 0000000..029d364 --- /dev/null +++ b/keeper/scripts/diff-indexer-vs-chain.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Walk every block in the user's trade history and compare +# `getPositionIds(user).length` on-chain to the indexer's +# `netQuantityAfter` at that point. +# +# Run: +# RPC="https://base-sepolia.g.alchemy.com/v2/" \ +# FUT="0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479" \ +# USER="0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4" \ +# bash scripts/diff-indexer-vs-chain.sh + +set -euo pipefail + +: "${RPC:?set RPC}" +: "${FUT:?set FUT}" +: "${USER:?set USER}" + +# (blockNumber, indexerNetQuantityAfter) pairs, ordered. +# Block 41546345 has two trades; both included. +TRADES=( + 41113449:-5 + 41113929:-18 + 41114063:-17 + 41114110:-16 + 41114131:-8 + 41152064:-11 + 41153016:-23 + 41154069:-18 + 41154191:-9 + 41154794:-23 + 41169381:-9 + 41169439:0 + 41190647:3 + 41198711:4 + 41198907:5 + 41198956:7 + 41198977:9 + 41199080:11 + 41199119:8 + 41542672:9 + 41544708:10 + 41546345:14 + 41546345:12 + 41546372:0 + 41546372:-5 +) + +printf "%-12s %-10s %-10s %-10s %s\n" "block" "indexer" "chainLen" "absMatch" "status" +printf "%-12s %-10s %-10s %-10s %s\n" "-----" "-------" "--------" "--------" "------" + +prev_match="" +for entry in "${TRADES[@]}"; do + block="${entry%%:*}" + indexer="${entry#*:}" + abs_indexer="${indexer#-}" + + raw=$(cast call "$FUT" "getPositionIds(address)(bytes32[])" "$USER" \ + --rpc-url "$RPC" --block "$block") + # raw looks like "[0x..., 0x..., 0x...]" or "[]" + if [ "$raw" = "[]" ]; then + chain_len=0 + else + chain_len=$(printf '%s' "$raw" | tr ',' '\n' | wc -l | tr -d ' ') + fi + + status="ok" + if [ "$chain_len" != "$abs_indexer" ]; then + status="MISMATCH" + fi + + printf "%-12s %-10s %-10s %-10s %s\n" "$block" "$indexer" "$chain_len" "$abs_indexer" "$status" +done diff --git a/keeper/src/chain.ts b/keeper/src/chain.ts index 88a8004..6c8502f 100644 --- a/keeper/src/chain.ts +++ b/keeper/src/chain.ts @@ -3,11 +3,13 @@ import { createWalletClient, http, type Account, + type Chain as ViemChain, type PublicClient, type WalletClient, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import type { Config } from "./config.ts"; +import { base, baseSepolia, hardhat } from "viem/chains"; +import type { Config, NetworkName } from "./config.ts"; export interface Chain { publicClient: PublicClient; @@ -15,6 +17,16 @@ export interface Chain { account: Account; } +/** + * Mapping from our config-level network names to the matching viem chain + * descriptor. Centralised here so transport/client wiring stays in one place. + */ +const VIEM_CHAINS: Record = { + hardhat, + "base-sepolia": baseSepolia, + "base-mainnet": base, +}; + /** * Builds the shared viem clients used by every module in the keeper. * The PublicClient is the one source of RPC reads (multicalls, event watchers, @@ -22,10 +34,11 @@ export interface Chain { * and futures liquidations — there is no separate validator key any more. */ export function createChain(config: Config): Chain { + const chain = VIEM_CHAINS[config.chain.network]; const transport = http(config.chain.rpcUrl); - const publicClient = createPublicClient({ transport }); + const publicClient = createPublicClient({ chain, transport }); const account = privateKeyToAccount(config.keeper.privateKey); - const walletClient = createWalletClient({ account, transport }); + const walletClient = createWalletClient({ account, chain, transport }); return { publicClient, walletClient, account }; } diff --git a/keeper/src/config.ts b/keeper/src/config.ts index c33114f..4bbd5ab 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -21,9 +21,18 @@ import type pino from "pino"; * - coordinator: cross-account ordering + concurrency * - runtime: healthcheck port, log level, dry-run, intervals */ +export type NetworkName = "hardhat" | "base-sepolia" | "base-mainnet"; + +export const SUPPORTED_NETWORKS: readonly NetworkName[] = [ + "hardhat", + "base-sepolia", + "base-mainnet", +] as const; + export interface Config { chain: { - network: string; + /** Logical network selector. Drives both `rpcUrl` and the viem chain object. */ + network: NetworkName; rpcUrl: string; /** Optional: prefer to use Goldsky webhooks over RPC event subscriptions. */ discoveryMode: "events" | "webhook" | "both"; @@ -74,6 +83,15 @@ export interface Config { * possibly cross any user's liquidation threshold. 0 = process every event. */ priceMoveTriggerBps: number; + /** + * Optional Chainlink ETH/USD `AggregatorProxy`. Used purely for logging: + * when set, every confirmed tx log gets a `gasCostUsd` field alongside + * `gasCostEth` so operators can read tx cost without doing wei-math at + * 4 a.m. When unset, the keeper skips the USD field and logs native cost + * only — no operational dependency, so deployments without a configured + * feed still run normally. + */ + ethUsdcFeedAddress?: Address; }; keeper: { /** Single signer used for both perps and futures liquidations. */ @@ -113,6 +131,97 @@ export interface Config { sweepIntervalMs: number; healthPort: number; logLevel: pino.Level; + /** + * Cadence of the gas-token balance check on the keeper signer in ms. + * Defaults to 5 minutes — frequent enough to catch a draining wallet + * within a few percent of its remaining headroom, infrequent enough + * that the log isn't noisy. + */ + balanceCheckIntervalMs: number; + /** + * Native gas balance below which the monitor logs WARN ("top up + * soon"). Sized for Base at ~current gas: 10 mETH ≈ a few hundred + * mid-sized txs of headroom. Wei. + */ + balanceLowWei: bigint; + /** + * Native gas balance below which the monitor logs ERROR ("top up + * NOW"). 1 mETH ≈ a handful of txs left before insufficient-funds + * reverts start. Wei. + */ + 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 acts as the futures `validator` and calls + * `closeDelivery(positionId, blameSeller)` on every active futures position + * the moment its `deliveryAt` is reached. Defaults to `false` so a stock + * keeper deployment doesn't accidentally start cash-settling positions on a + * chain where it isn't the configured validator. + * + * Requires the keeper signer (`LIQUIDATOR_PRIVATE_KEY`) to equal the + * Futures contract's `validatorAddress` — otherwise `closeDelivery` reverts + * `OnlyValidatorOrPositionParticipant` and the module logs the skip. + */ + enabled: boolean; + /** + * Side blamed for the breach when settling at delivery start. The breach + * penalty is paid by the blamed party to the counterparty; with + * `breachPenaltyRatePerDay = 0` (the default in production) the choice is + * cosmetic. Set `true` to blame the seller (default: they're the ones + * who didn't deliver hashrate), `false` to blame the buyer. + */ + blameSeller: boolean; + /** + * Cadence of the periodic safety-net sweep over tracked positions. Picks + * up anything the per-position timers missed (process restarts, missed + * `PositionCreated` events, clock skew). Live timers are the hot path. + */ + sweepIntervalMs: number; + /** + * Delay after `position.deliveryAt` before attempting `closeDelivery`. + * Adds a small cushion so the on-chain `block.timestamp >= deliveryAt` + * guard is satisfied even when local and miner clocks drift slightly. + */ + settleDelayMs: number; + /** + * Manual seed list of addresses whose futures positions the delivery + * coordinator should index immediately on boot, in addition to whatever + * the participant tracker has discovered. Useful as an emergency lever + * when log backfill fails (e.g. Alchemy free tier capping `eth_getLogs` + * to 10 blocks) and a known user has an unsettled position the + * coordinator would otherwise never see. Comma-separated EVM addresses. + */ + bootstrapUsers: readonly Address[]; + /** + * Maximum number of `closeDelivery` calls bundled into a single + * `Futures.multicall(bytes[])` 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 `closeDelivery` is roughly 200-300k gas, so 50 is + * conservative (~15M gas worst case). Set lower if your participants + * have unusually expensive settlement paths. + */ + maxBatchSize: number; }; } @@ -142,6 +251,20 @@ function requireAddress(name: string): Address { return getAddress(value); } +/** + * Like `requireAddress` but returns `undefined` when the env var is unset + * or empty. Still validates the address shape when present so a typo fails + * at boot rather than silently rendering a feed inert. + */ +function optionalAddress(name: string): Address | undefined { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return undefined; + if (!isAddress(raw, { strict: false })) { + throw new Error(`Environment variable ${name} must be a valid EVM address, got "${raw}"`); + } + return getAddress(raw); +} + /** * Read an env var that must be a 0x-prefixed hex string of the given byte length * (omit `bytes` to accept any length). Used for private keys and similar secrets. @@ -152,21 +275,78 @@ function requireHex(name: string, bytes?: number): Hex { throw new Error(`Environment variable ${name} must be a 0x-prefixed hex string`); } if (bytes !== undefined && value.length !== 2 + bytes * 2) { - throw new Error(`Environment variable ${name} must be ${bytes} bytes (${2 + bytes * 2} chars), got ${value.length}`); + throw new Error( + `Environment variable ${name} must be ${bytes} bytes (${2 + bytes * 2} chars), got ${value.length}`, + ); } return value; } +/** + * Parse a comma/whitespace-separated list of EVM addresses from an optional + * env var. Each entry is checksummed via `getAddress`; an invalid entry + * throws so a typo in deployment config fails fast instead of silently + * dropping the user. + */ +function parseAddressList(name: string): readonly Address[] { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return []; + const parts = raw + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return parts.map((value) => { + if (!isAddress(value, { strict: false })) { + throw new Error(`Environment variable ${name} contains invalid address "${value}"`); + } + return getAddress(value); + }); +} + +function requireNetwork(): NetworkName { + const value = requireEnv("NETWORK"); + if (!(SUPPORTED_NETWORKS as readonly string[]).includes(value)) { + throw new Error( + `NETWORK must be one of ${SUPPORTED_NETWORKS.join("|")}, got "${value}"`, + ); + } + return value as NetworkName; +} + +/** + * Build the RPC URL for the chosen network. Hardhat resolves to the local node + * and ignores `ALCHEMY_API_KEY`. An explicit `ETH_NODE_ADDRESS` always wins so + * operators can point at a custom RPC without touching this logic. + */ +function resolveRpcUrl(network: NetworkName): string { + const explicit = process.env.ETH_NODE_ADDRESS; + if (explicit) return explicit; + + if (network === "hardhat") { + return process.env.HARDHAT_RPC_URL ?? "http://127.0.0.1:8545"; + } + + const alchemySubdomain: Record, string> = { + "base-sepolia": "base-sepolia", + "base-mainnet": "base-mainnet", + }; + const apiKey = requireEnv("ALCHEMY_API_KEY"); + return `https://${alchemySubdomain[network]}.g.alchemy.com/v2/${apiKey}`; +} + export function loadConfig(): Config { - const discoveryMode = (process.env.DISCOVERY_MODE ?? "events") as Config["chain"]["discoveryMode"]; + const discoveryMode = (process.env.DISCOVERY_MODE ?? + "events") as Config["chain"]["discoveryMode"]; if (!["events", "webhook", "both"].includes(discoveryMode)) { throw new Error(`DISCOVERY_MODE must be one of events|webhook|both, got "${discoveryMode}"`); } + const network = requireNetwork(); + return { chain: { - network: requireEnv("NETWORK"), - rpcUrl: requireEnv("ETH_NODE_ADDRESS"), + network, + rpcUrl: resolveRpcUrl(network), discoveryMode, backfillFromBlock: optionalBigInt("BACKFILL_FROM_BLOCK"), backfillChunkSize: BigInt(process.env.BACKFILL_CHUNK_SIZE ?? "10000"), @@ -182,9 +362,10 @@ export function loadConfig(): Config { }, pme: { address: requireAddress("PME_ADDRESS") }, oracle: { - hashpriceUsdcAddress: requireAddress("HASHPRICE_USDC_ADDRESS"), - btcUsdcFeedAddress: requireAddress("BTC_USDC_FEED_ADDRESS"), + hashpriceUsdcAddress: requireAddress("HASHPRICE_USD_ADDRESS"), + btcUsdcFeedAddress: requireAddress("BTC_USD_FEED_ADDRESS"), priceMoveTriggerBps: Number(process.env.PRICE_MOVE_TRIGGER_BPS ?? "1"), + ethUsdcFeedAddress: optionalAddress("ETH_USD_FEED_ADDRESS"), }, keeper: { privateKey: requireHex("LIQUIDATOR_PRIVATE_KEY", 32), @@ -213,6 +394,23 @@ export function loadConfig(): Config { sweepIntervalMs: Number(process.env.SWEEP_INTERVAL_MS ?? "60000"), healthPort: Number(process.env.HEALTH_PORT ?? "3000"), logLevel: (process.env.LOG_LEVEL as pino.Level) ?? "info", + balanceCheckIntervalMs: Number(process.env.BALANCE_CHECK_INTERVAL_MS ?? "300000"), + // Defaults: 10 mETH low, 1 mETH critical. Override via env vars + // when running on a chain with materially different gas prices. + 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", + blameSeller: process.env.DELIVERY_BLAME_SELLER !== "false", + sweepIntervalMs: Number(process.env.DELIVERY_SWEEP_INTERVAL_MS ?? "60000"), + settleDelayMs: Number(process.env.DELIVERY_SETTLE_DELAY_MS ?? "5000"), + bootstrapUsers: parseAddressList("DELIVERY_BOOTSTRAP_USERS"), + maxBatchSize: Number(process.env.DELIVERY_MAX_BATCH_SIZE ?? "50"), }, }; } diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts new file mode 100644 index 0000000..27d90af --- /dev/null +++ b/keeper/src/delivery/coordinator.ts @@ -0,0 +1,1142 @@ +import { + BaseError, + ContractFunctionRevertedError, + encodeFunctionData, + zeroAddress, + type Address, + type Hex, + type Log, +} from "viem"; +import { withUnstickRetry } from "../tx/unstick.ts"; +import type pino from "pino"; +import { FuturesAbi } from "futures-marketplace/Futures.ts"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; +import { formatGasCost } from "../tx/gasCost.ts"; + +/** + * Optional keeper module that calls `Futures.closeDelivery(positionId, blameSeller)` + * on every active futures position the moment its `deliveryAt` is reached. + * Settlement happens at the *current* market price for the full delivery + * window (positionElapsedTime = 0 → the entire position cash-settles at + * `getMarketPrice()`), avoiding the need for any physical hashrate delivery. + * + * Authorization: `closeDelivery` is gated by either + * 1. `_msgSender() == validatorAddress` (this module's path), or + * 2. `_msgSender() == position.{buyer,seller}` + * + * The keeper's signer must therefore equal the Futures contract's + * `validatorAddress` for this module to do anything. If it doesn't, every + * settlement attempt simulates as `OnlyValidatorOrPositionParticipant` and + * the module logs the skip without crashing — useful in dev / dry-run setups. + * + * Hot path is event-driven: + * + * PositionCreated ─▶ schedule one-shot timer at deliveryAt + settleDelay + * PositionClosed ─▶ cancel the timer + drop from index + * timer fires ─▶ settle(positionId) + * + * Cold-start safety net (two redundant paths — either alone is sufficient): + * + * bootstrapFromUsers(addrs) ─▶ for each address, read `getPositionIds(user)` + * and `getPositionById(id)` via multicall, then + * index whatever positions are still alive + * on-chain. View-only — works on any RPC, + * including providers that rate-limit + * `eth_getLogs` (Alchemy free tier caps at + * 10 blocks, which makes log backfill + * impractical for any non-trivial range). + * This is the recommended primary path and + * is wired automatically in `index.ts` from + * `tracker.onAdded` and once at boot from + * `tracker.list()`. + * backfill(fromBlock) ─▶ replay PositionCreated/PositionClosed in + * chunks. Discovers positions even for + * participants the tracker doesn't know + * about, but breaks on rate-limited + * providers — keep `BACKFILL_FROM_BLOCK` + * small or unset on Alchemy free. + * sweep() ─▶ every `sweepIntervalMs`, scan tracked + * positions for any in + * `[deliveryAt, deliveryAt + duration]` that + * haven't been settled — covers dropped + * events, timer drift, post-restart recovery, + * and oracle-staleness retries. + * + * Single source of truth for "is this position alive": the contract emits + * `PositionClosed` at the end of every `_removePosition`, including the + * cash-settlement path inside `closeDelivery` itself. The module never has + * to track its own settled-set across restarts — once settled, the contract + * removes the position and `getPositionById(id).seller == 0` permanently. + */ +export class DeliveryCoordinator { + /** Active positions known to the module: positionId → metadata. */ + private readonly tracked = new Map(); + /** One-shot timers keyed by positionId. Cleared on settle / close / stop. */ + private readonly timers = new Map(); + /** Set of positions with an in-flight `settle()` — coalesces duplicate triggers. */ + private readonly inflight = new Set(); + /** + * Per-revert "we've already warned about this once" set so persistent + * operational misconfigs (wrong validator key, missed delivery window) + * surface loudly on first hit but don't flood the log on every sweep. + * Keyed by `:` so each position warns once per type + * per process — clears nothing across restarts, which is what we want. + */ + private readonly warned = new Set(); + /** + * Serialized broadcast chain: every `attemptSettle` awaits the previous + * one before sending its own tx. The keeper has a single signer, so two + * concurrent `writeContract` calls would race on the same nonce and one + * would revert. Sweeps fire many candidates in parallel (e.g. multiple + * positions sharing one `deliveryAt`); without this, the second-onward + * txs would be rejected by the node. + */ + private txChain: Promise = Promise.resolve(); + /** Disposers returned by `watchContractEvent`. */ + private unwatchers: Array<() => void> = []; + private sweepTimer: NodeJS.Timeout | undefined; + /** Cached `deliveryDurationDays` (read once at start). */ + private deliveryDurationSeconds: bigint | undefined; + private running = false; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + private readonly ethUsdFeed: EthUsdFeed | undefined; + + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + ethUsdFeed?: EthUsdFeed, + ) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ component: "deliveryCoordinator" }); + // Optional — see FuturesVenue for the rationale. Used only to enrich + // the two confirmed-tx logs (batched `multicall` and single + // `closeDelivery`) with a `gasCostUsd` field. + this.ethUsdFeed = ethUsdFeed; + } + + /** + * Subscribes to `PositionCreated` / `PositionClosed`, primes the duration + * cache, and starts the periodic safety-net sweep. Idempotent. + * + * Backfill is the caller's responsibility (via `backfill(fromBlock)`) so + * the runtime can sequence it after live subscriptions are wired — same + * pattern as `ParticipantTracker`. + */ + async start(): Promise { + if (this.running) return; + this.running = true; + + const days = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "deliveryDurationDays", + })) as number; + this.deliveryDurationSeconds = BigInt(days) * 86_400n; + this.logger.info( + { deliveryDurationDays: days, blameSeller: this.config.delivery.blameSeller }, + "delivery coordinator starting", + ); + + // Pre-flight: verify the keeper signer is actually authorised to call + // `closeDelivery`. If not, every settle attempt will silently revert + // `OnlyValidatorOrPositionParticipant` inside simulate, and the only + // operator-visible signal is "no settlements happen" — easy to miss + // until a user reports a stuck position. We fail fast instead: throw, + // bubble up to `main().catch` → `process.exit(1)`. The orchestrator + // (k8s, systemd, docker restart-policy) sees the crash, cycles the + // pod, and standard infra alerting (CrashLoopBackOff, healthcheck + // 503, sentry on-error) pages on-call without any keeper-specific + // notification plumbing. A code restart is not required to recover — + // just rotate `LIQUIDATOR_PRIVATE_KEY` to match + // `Futures.validatorAddress()` (or unset `DELIVERY_KEEPER_ENABLED`) + // and the next pod will start cleanly. + await this.assertValidatorAuthorised(); + + this.unwatchers.push( + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: FuturesAbi, + eventName: "PositionCreated", + onLogs: (logs) => this.onPositionCreated(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: FuturesAbi, + eventName: "PositionClosed", + onLogs: (logs) => this.onPositionClosed(logs), + }), + ); + + this.sweepTimer = setInterval(() => { + void this.sweep(); + }, this.config.delivery.sweepIntervalMs); + } + + /** Tears down all subscriptions, timers, and the sweep loop. Idempotent. */ + stop(): void { + if (!this.running) return; + this.running = false; + + if (this.sweepTimer !== undefined) { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + } + for (const t of this.timers.values()) clearTimeout(t); + this.timers.clear(); + + for (const u of this.unwatchers) { + try { + u(); + } catch (err) { + this.logger.warn({ err }, "delivery: unwatcher threw — continuing shutdown"); + } + } + this.unwatchers = []; + } + + /** + * Reads `Futures.validatorAddress()` and compares it to the keeper's + * signer. Throws when they don't match — caller (`start()`) propagates + * the throw up to `main().catch` so the process exits non-zero. + * + * The check is mandatory because the alternative (silent skip on every + * `closeDelivery` revert) is invisible to operators at the default + * `info` log level. A crash makes the misconfiguration impossible to + * miss: the orchestrator restart loop and healthcheck 503 are the + * existing operator-alert path; we don't need a parallel notification + * channel just for delivery. + */ + private async assertValidatorAuthorised(): Promise { + const validator = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "validatorAddress", + })) as Address; + const signer = this.chain.account.address; + if (validator.toLowerCase() === signer.toLowerCase()) { + this.logger.info( + { signer, validator, futures: this.config.futures.address }, + "delivery: validator alignment OK", + ); + return; + } + const message = + "DELIVERY_KEEPER_ENABLED=true but the keeper signer is not the futures validator. " + + `Futures.validatorAddress()=${validator} but LIQUIDATOR_PRIVATE_KEY → ${signer}. ` + + "Either rotate LIQUIDATOR_PRIVATE_KEY to match the validator, or unset " + + "DELIVERY_KEEPER_ENABLED. Refusing to start so this is impossible to miss."; + this.logger.error( + { signer, validator, futures: this.config.futures.address }, + message, + ); + throw new Error(message); + } + + /** + * Replay `PositionCreated` and `PositionClosed` in `[fromBlock, head]` so + * the in-memory index reflects every position the contract still considers + * active. Closed positions cancel their `created` entry as the same scan + * runs in chronological order — no second pass needed. + * + * After backfill, kicks one immediate sweep so any positions whose + * `deliveryAt` has already passed get settled without waiting for the + * sweep timer's first tick. + */ + async backfill(fromBlock: bigint, chunkSize: bigint): Promise { + if (chunkSize <= 0n) { + throw new Error(`delivery backfill chunkSize must be positive, got ${chunkSize}`); + } + const head = await this.chain.publicClient.getBlockNumber(); + if (fromBlock > head) { + this.logger.warn( + { fromBlock: fromBlock.toString(), head: head.toString() }, + "delivery backfill fromBlock > head — nothing to do", + ); + return; + } + + this.logger.info( + { + fromBlock: fromBlock.toString(), + head: head.toString(), + chunkSize: chunkSize.toString(), + }, + "delivery backfill: starting", + ); + + // Single scan over both events per chunk so creates and closes interleave + // in block order — a position created and then closed in the same chunk + // never lingers in `tracked` after the chunk drains. + let chunkErrors = 0; + for (let start = fromBlock; start <= head; start += chunkSize) { + const end = start + chunkSize - 1n > head ? head : start + chunkSize - 1n; + try { + const [created, closed] = await Promise.all([ + this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: FuturesAbi, + eventName: "PositionCreated", + fromBlock: start, + toBlock: end, + }), + this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: FuturesAbi, + eventName: "PositionClosed", + fromBlock: start, + toBlock: end, + }), + ]); + this.onPositionCreated(created as unknown as readonly Log[]); + this.onPositionClosed(closed as unknown as readonly Log[]); + } catch (err) { + chunkErrors++; + this.logger.error( + { err, from: start.toString(), to: end.toString() }, + "delivery backfill chunk failed", + ); + } + } + + this.logger.info( + { tracked: this.tracked.size, head: head.toString(), chunkErrors }, + "delivery backfill: complete", + ); + + await this.sweep(); + } + + /** + * View-based discovery: read every still-alive futures position belonging + * to `users` and index them. Trailing `sweep()` settles anything past + * `deliveryAt`. Robust against `eth_getLogs` rate-limit caps because it + * never scans logs. + * + * Wired in `index.ts` from + * - `tracker.onAdded` (per-user, on every newly-discovered participant) + * - the boot sequence's `tracker.list()` (one batched pass after + * `tracker.backfill` finishes) + * so any participant the tracker eventually discovers — by webhook, live + * event, or backfill — also has their futures positions indexed. + * + * Two-stage multicall to keep the contract surface narrow: stage 1 reads + * `getPositionIds(user)` for every user, stage 2 hydrates each id via + * `getPositionById`. Closed positions (returned with `seller == address(0)` + * by the `delete positions[id]` in `_removePosition`) are filtered out. + */ + async bootstrapFromUsers(users: readonly Address[]): Promise { + if (users.length === 0) { + this.logger.info( + { users: 0, total: this.tracked.size }, + "delivery bootstrap: no users to scan (tracker found none and no DELIVERY_BOOTSTRAP_USERS provided)", + ); + return; + } + + const positionIdLists = (await this.chain.publicClient.multicall({ + contracts: users.map((u) => ({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getPositionIds" as const, + args: [u] as const, + })), + allowFailure: false, + })) as readonly (readonly Hex[])[]; + + // `getPositionIds(user)` returns positions where the user is EITHER + // buyer OR seller, so a single position with both participants + // tracked (typical) shows up twice across the per-user calls. Dedup + // so the operator-facing counter reflects distinct positions, not + // raw entries — operators kept asking "why does positionsOnChain not + // match tracked.size?" because they never see the same id twice on + // a block explorer. + const uniqueOnChain = new Set(); + const allIds: Hex[] = []; + for (const ids of positionIdLists) { + for (const id of ids) { + uniqueOnChain.add(id); + if (this.tracked.has(id)) continue; + allIds.push(id); + } + } + + let indexed = 0; + if (allIds.length > 0) { + indexed = await this.indexPositions(allIds); + } + + // Operator-readable summary regardless of whether anything new was + // indexed. The "total" / "pastDue" / "nextDueAt" tuple is the answer + // to "is the delivery keeper actually doing anything?": + // - total=0 → wallet has nothing to settle (healthy idle) + // - pastDue>0 → next sweep tick attempts a multicall + // - pastDue=0 + ETA → keeper is correctly waiting for the timer + // at `nextDueAt` (no bug — settlement isn't + // valid before deliveryAt on-chain) + const pastDue = this.countPastDuePositions(); + const nextDueAt = this.findEarliestDeliveryAt(); + this.logger.info( + { + users: users.length, + uniquePositionsOnChain: uniqueOnChain.size, + indexed, + total: this.tracked.size, + pastDue, + nextDueAt: + nextDueAt !== undefined + ? new Date(Number(nextDueAt) * 1000).toISOString() + : null, + }, + "delivery bootstrap: complete", + ); + + await this.sweep(); + } + + /** Count tracked positions whose deliveryAt is at or before chain head. */ + private countPastDuePositions(): number { + // Approximate using wall-clock — within a block of chain time on + // any production network, accurate enough for an operator-facing + // summary. The actual sweep uses `block.timestamp` for correctness. + const nowSec = BigInt(Math.floor(Date.now() / 1000)); + let n = 0; + for (const pos of this.tracked.values()) { + if (nowSec >= pos.deliveryAt) n++; + } + return n; + } + + /** + * Earliest `deliveryAt` across all tracked positions. Returned to the + * boot summary as "the next time the keeper expects to do work" so an + * operator can sanity-check "all 5 positions are due in 3 days, that's + * why nothing's happening" without having to hop to a block explorer. + * `undefined` when there are no tracked positions. + */ + private findEarliestDeliveryAt(): bigint | undefined { + let earliest: bigint | undefined; + for (const pos of this.tracked.values()) { + if (earliest === undefined || pos.deliveryAt < earliest) earliest = pos.deliveryAt; + } + return earliest; + } + + /** + * Single-user variant of `bootstrapFromUsers` — exposed separately so + * `tracker.onAdded` can wire it without paying the multicall overhead + * for one user. Errors are caught and logged: the listener path must + * never throw into the tracker. + */ + async indexUserPositions(user: Address): Promise { + let ids: readonly Hex[]; + try { + ids = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getPositionIds", + args: [user], + })) as readonly Hex[]; + } catch (err) { + this.logger.error({ err, user }, "delivery: getPositionIds failed"); + return; + } + const fresh = ids.filter((id) => !this.tracked.has(id)); + if (fresh.length === 0) return; + try { + const indexed = await this.indexPositions(fresh); + // INFO (not debug): operator-visible signal that the keeper + // discovered a user's futures and is now responsible for settling + // them. If you ever wonder "did the keeper see my new account?" + // this is the line you grep for. + if (indexed > 0) { + this.logger.info( + { user, indexed, total: this.tracked.size }, + "delivery: indexed user's futures positions", + ); + } + } catch (err) { + this.logger.error({ err, user }, "delivery: indexPositions failed"); + } + } + + /** + * Internal: hydrate `ids` via multicalled `getPositionById` and upsert + * the live ones (`seller != 0`) into the tracked map plus a per-position + * timer. Returns the number of newly-indexed positions. + */ + private async indexPositions(ids: readonly Hex[]): Promise { + const positions = (await this.chain.publicClient.multicall({ + contracts: ids.map((id) => ({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + allowFailure: false, + })) as readonly { + seller: Address; + buyer: Address; + deliveryAt: bigint; + }[]; + + let added = 0; + for (let i = 0; i < ids.length; i++) { + const id = ids[i] as Hex; + const pos = positions[i] as { seller: Address; buyer: Address; deliveryAt: bigint }; + // `_removePosition` deletes the slot — `seller == 0` means already + // closed/settled. Skip without touching state. + if (pos.seller === zeroAddress) continue; + if (this.tracked.has(id)) continue; + const tracked: TrackedPosition = { + positionId: id, + deliveryAt: pos.deliveryAt, + seller: pos.seller, + buyer: pos.buyer, + }; + this.tracked.set(id, tracked); + this.scheduleTimer(tracked); + added++; + } + return added; + } + + /** + * Scan all tracked positions; settle any whose `deliveryAt` is past and + * whose settlement window has not yet expired. Skips positions with an + * in-flight settle to avoid duplicate sends. Public for tests. + * + * Uses the chain's latest `block.timestamp` rather than `Date.now()` so + * the sweep agrees with the contract's `_msgSender == validator` window + * checks (`block.timestamp >= deliveryAt`, `block.timestamp <= deliveryAt + * + duration`). On hardhat with `evm_setNextBlockTimestamp`, chain time + * and wall-clock can diverge by years; in production they're within + * one block of each other so this read is essentially free. + */ + async sweep(): Promise { + const latestBlock = await this.chain.publicClient.getBlock(); + const nowSec = latestBlock.timestamp; + const candidates: TrackedPosition[] = []; + const window = this.deliveryDurationSeconds ?? 0n; + + for (const pos of this.tracked.values()) { + if (this.inflight.has(pos.positionId)) continue; + if (nowSec < pos.deliveryAt) continue; + // After `deliveryAt + duration` the contract reverts `PositionDeliveryExpired`. + // Skip — there's no entry point that can settle the position any more. + if (window > 0n && nowSec > pos.deliveryAt + window) { + this.logger.warn( + { + positionId: pos.positionId, + deliveryAt: pos.deliveryAt.toString(), + now: nowSec.toString(), + }, + "delivery: settlement window expired — position abandoned", + ); + this.tracked.delete(pos.positionId); + const t = this.timers.get(pos.positionId); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(pos.positionId); + } + continue; + } + candidates.push(pos); + } + + if (candidates.length === 0) { + // Visibility for "the sweep ran but found nothing" — at debug so + // a healthy idle keeper isn't noisy in tails. Tracked-but-not-yet- + // due counts in the message let an operator confirm the index is + // populated even when no work is pending. + this.logger.debug( + { tracked: this.tracked.size, pendingFuture: this.tracked.size }, + "delivery sweep: nothing past-due", + ); + return; + } + // INFO so an active sweep is visible in default-config tails. Sweeps + // are bursty (most ticks find nothing, occasional ticks settle a + // batch) so this won't flood logs. + this.logger.info( + { candidates: candidates.length, tracked: this.tracked.size }, + "delivery sweep: settling", + ); + // Batch via `Futures.multicall(bytes[])` (OZ MulticallUpgradeable) so + // every settlement in this sweep tick rides one transaction → one + // nonce → no `replacement transaction underpriced` race against + // concurrent manual sends or stale pending txs from a previous run. + // We cap batch size to keep gas usage bounded; large sweeps spread + // across multiple batches, each its own serial txChain entry. + const ids = candidates.map((c) => c.positionId); + const max = Math.max(1, this.config.delivery.maxBatchSize); + for (let i = 0; i < ids.length; i += max) { + const slice = ids.slice(i, i + max); + try { + await this.settleBatch(slice); + } catch (err) { + this.logger.error( + { err, batchSize: slice.length }, + "delivery sweep: batch threw — continuing with next batch", + ); + } + } + } + + /** Public for tests. Number of positions currently scheduled for settlement. */ + size(): number { + return this.tracked.size; + } + + /** Public for tests. Whether `positionId` is currently scheduled. */ + has(positionId: Hex): boolean { + return this.tracked.has(positionId); + } + + /** + * Public for tests. Settles a single position via the batch path + * (`settleBatch([id])`). Kept for tests and as a stable single-id entry + * point — the actual broadcast still goes through `Futures.multicall` + * with one entry, so the nonce / serialization model is identical to + * multi-id sweeps. + */ + async settle(positionId: Hex): Promise { + await this.settleBatch([positionId]); + } + + /** + * Bundles up to `maxBatchSize` `closeDelivery` calls into a single + * `Futures.multicall(bytes[])` transaction. OZ `MulticallUpgradeable` + * uses `delegatecall` per entry, so `msg.sender` is preserved and the + * contract's `_msgSender == validator || _msgSender == participant` + * auth check is satisfied identically to a direct call. + * + * Two-phase to keep one bad apple from spoiling the batch: + * 1. Per-id `simulateContract` in parallel — drops candidates that + * would revert (already-settled, expired window, oracle stale, etc). + * Each revert is reported through the same severity taxonomy as + * individual settles, so an operator-actionable revert + * (`OnlyValidatorOrPositionParticipant`) still surfaces at error + * level even when discovered as part of a batch. + * 2. One `multicall` write tx for the survivors. If the *write* + * reverts (rare — simulate-then-write race), we fall back to + * per-id `attemptSettle` so a single newly-poisoned id can't + * block the whole sweep tick. + * + * Serialized through `txChain` so two batches (e.g. two slices of a + * sweep larger than `maxBatchSize`) ride sequential nonces. Per-id + * `inflight` set still applies so a slow batch can't be re-queued + * concurrently from a timer fire mid-sweep. + */ + async settleBatch(positionIds: readonly Hex[]): Promise { + const fresh: Hex[] = []; + for (const id of positionIds) { + if (this.inflight.has(id)) continue; + fresh.push(id); + this.inflight.add(id); + } + if (fresh.length === 0) return; + const next = this.txChain.then(() => this.attemptBatch(fresh)); + this.txChain = next.catch(() => undefined); + try { + await next; + } finally { + for (const id of fresh) this.inflight.delete(id); + } + } + + /** + * Phase 1: simulate every candidate, classify outcomes, build the + * settleable subset. Phase 2: one batched write or fall through to + * per-id retries if the batch tx itself fails. + */ + private async attemptBatch(positionIds: readonly Hex[]): Promise { + const blameSeller = this.config.delivery.blameSeller; + + type SimParams = Parameters[0]; + const simResults = await Promise.allSettled( + positionIds.map((id) => + this.chain.publicClient.simulateContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "closeDelivery", + args: [id, blameSeller], + account: this.chain.account, + } as unknown as SimParams), + ), + ); + + const settleable: Hex[] = []; + for (let i = 0; i < positionIds.length; i++) { + const id = positionIds[i] as Hex; + const r = simResults[i] as PromiseSettledResult; + if (r.status === "fulfilled") { + settleable.push(id); + continue; + } + const decoded = decodeRecoverableRevert(r.reason); + if (decoded !== undefined) { + this.logRecoverableRevert(decoded, id, blameSeller); + if (decoded === "PositionNotExists" || decoded === "PositionDeliveryExpired") { + this.tracked.delete(id); + const t = this.timers.get(id); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(id); + } + } + continue; + } + // Unknown revert — log error but don't kill the rest of the batch. + this.logger.error( + { err: r.reason, positionId: id }, + "delivery: simulate failed with non-recoverable error — skipping from batch", + ); + } + + if (settleable.length === 0) { + this.logger.debug( + { batchSize: positionIds.length }, + "delivery batch: nothing to broadcast after simulate filter", + ); + return; + } + + if (this.config.keeper.dryRun) { + this.logger.info( + { batchSize: settleable.length }, + "[dryRun] would call Futures.multicall(closeDelivery × N)", + ); + for (const id of settleable) this.tracked.delete(id); + return; + } + + // Encode each closeDelivery into bytes for OZ multicall(bytes[]). + // Encoding can only fail on a malformed positionId (e.g. wrong + // bytes32 width from a corrupted RPC read). We isolate that + // per-position rather than letting one bad id swallow the whole + // batch — same "one bad apple" guarantee we extend through simulate. + const calldatas: Hex[] = []; + const encodableIds: Hex[] = []; + for (const id of settleable) { + try { + const data = encodeFunctionData({ + abi: FuturesAbi, + functionName: "closeDelivery", + args: [id, blameSeller], + }); + calldatas.push(data); + encodableIds.push(id); + } catch (err) { + this.logger.error( + { err, positionId: id }, + "delivery: encodeFunctionData threw — dropping malformed id from batch", + ); + } + } + if (calldatas.length === 0) return; + + type WriteParams = Parameters[0]; + let hash: Hex; + try { + // `withUnstickRetry` is the auto-recovery for the most common + // tx-submission failure on this signer: a stuck pending tx from + // a previous keeper run (or a previous attempt that timed out + // mid-broadcast). On `replacement transaction underpriced` it + // walks the wallet's pending nonces, evicts each with a 0-value + // self-transfer at 3× current gas, then retries our multicall + // exactly once. Anything still wrong on retry 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) { + // Tx-submission failures (nonce races, replacement underpriced, + // mempool-full, transient RPC errors) are recoverable: the next + // sweep will retry. We do NOT want to crash the keeper here — + // unhandled rejection on a setTimeout-fired batch took the whole + // process down in production. + if (isTransientTxError(err)) { + this.logger.warn( + { err, batchSize: settleable.length }, + "delivery batch: tx submission failed transiently — sweep will retry", + ); + return; + } + // Non-transient revert — could be one position turned bad between + // simulate and write (state moved). Fall back to per-id attempts + // so the others still settle on this sweep. + this.logger.warn( + { err, batchSize: encodableIds.length }, + "delivery batch: write reverted — falling back to per-position retries", + ); + for (const id of encodableIds) { + try { + await this.attemptSettle(id); + } catch (innerErr) { + this.logger.error( + { err: innerErr, positionId: id }, + "delivery: per-position fallback failed — leaving for next sweep", + ); + } + } + return; + } + + const receipt = await this.chain.publicClient.waitForTransactionReceipt({ + hash, + confirmations: this.config.coordinator.confirmationBlocks, + }); + this.logger.info( + { + hash, + blockNumber: receipt.blockNumber.toString(), + batchSize: encodableIds.length, + ...formatGasCost(receipt, this.ethUsdFeed), + }, + "delivery batch: multicall confirmed", + ); + + for (const id of encodableIds) { + this.tracked.delete(id); + const t = this.timers.get(id); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(id); + } + } + } + + private async attemptSettle(positionId: Hex): Promise { + const blameSeller = this.config.delivery.blameSeller; + const args = [positionId, blameSeller] as const; + + type SimParams = Parameters[0]; + type SimReturn = Awaited>; + let request: SimReturn["request"]; + try { + const sim = (await this.chain.publicClient.simulateContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "closeDelivery", + args, + account: this.chain.account, + } as unknown as SimParams)) as SimReturn; + request = sim.request; + } catch (err) { + const decoded = decodeRecoverableRevert(err); + if (decoded !== undefined) { + this.logRecoverableRevert(decoded, positionId, blameSeller); + // PositionNotExists / PositionDeliveryExpired → contract no longer + // accepts settlement. Drop from the index so we don't keep retrying. + if (decoded === "PositionNotExists" || decoded === "PositionDeliveryExpired") { + this.tracked.delete(positionId); + const t = this.timers.get(positionId); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(positionId); + } + } + return; + } + throw err; + } + + if (this.config.keeper.dryRun) { + this.logger.info({ positionId, blameSeller }, "[dryRun] would call closeDelivery"); + this.tracked.delete(positionId); + return; + } + + type WriteParams = Parameters[0]; + const hash = await this.chain.walletClient.writeContract(request as unknown as WriteParams); + const receipt = await this.chain.publicClient.waitForTransactionReceipt({ + hash, + confirmations: this.config.coordinator.confirmationBlocks, + }); + this.logger.info( + { + positionId, + blameSeller, + hash, + blockNumber: receipt.blockNumber.toString(), + ...formatGasCost(receipt, this.ethUsdFeed), + }, + "delivery: closeDelivery confirmed", + ); + this.tracked.delete(positionId); + const t = this.timers.get(positionId); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(positionId); + } + } + + // ── log handlers ───────────────────────────────────────────────────────── + // Mirror the live-and-backfill duality used by ParticipantTracker — the + // same handler is fed both `watchContractEvent` callbacks and historical + // `getContractEvents` results, so a future ABI rename surfaces here once. + + private onPositionCreated(logs: readonly Log[]): void { + type Args = { + positionId?: Hex; + seller?: Address; + buyer?: Address; + deliveryAt?: bigint; + }; + let added = 0; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if ( + args === undefined || + args.positionId === undefined || + args.deliveryAt === undefined || + args.seller === undefined || + args.buyer === undefined + ) { + continue; + } + const positionId = args.positionId; + // Backfill can replay an event we already indexed (live watcher + // overlap). De-dupe on positionId so we don't double-schedule. + if (this.tracked.has(positionId)) continue; + const tracked: TrackedPosition = { + positionId, + deliveryAt: args.deliveryAt, + seller: args.seller, + buyer: args.buyer, + }; + this.tracked.set(positionId, tracked); + this.scheduleTimer(tracked); + added++; + // INFO per *new* position so the operator sees live activity in + // real time. We log inside the loop (not after) so each id and + // its `deliveryAt` is searchable in tails — useful when chasing + // a specific position's lifecycle. Backfill replays go through + // the dedupe `continue` above and stay silent. + this.logger.info( + { + positionId, + seller: args.seller, + buyer: args.buyer, + deliveryAt: args.deliveryAt.toString(), + total: this.tracked.size, + }, + "delivery: new position indexed from live event", + ); + } + if (added === 0) return; + } + + private onPositionClosed(logs: readonly Log[]): void { + type Args = { positionId?: Hex }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.positionId === undefined) continue; + const positionId = args.positionId; + this.tracked.delete(positionId); + const t = this.timers.get(positionId); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(positionId); + } + } + } + + /** + * Differentiated logging for the recoverable-revert taxonomy. Three buckets: + * + * debug — transient, will retry on the next sweep with no operator + * action needed (pre-window, oracle stale, oracle invalid). + * info — terminal but benign: the contract no longer accepts settlement + * because someone else already did it. We drop and move on. + * error — operational red flag that should page. Deduped per + * `(revert, positionId)` so a stuck signer doesn't flood every + * sweep tick — the first hit per position is the loud one, + * subsequent ones drop to debug. Restart of the keeper resets + * the dedupe set, so a fix-and-restart re-enables the error + * for any new occurrences. We use `error` rather than `warn` + * because both are unrecoverable without operator action: the + * position will *never* be cash-settled by this keeper unless + * the cause is fixed: + * - signer != validator → keeper has no way to authorize + * `closeDelivery`; rotate LIQUIDATOR_PRIVATE_KEY to match + * `Futures.validatorAddress()` or have the position + * participant call `closeDelivery` themselves. + * - past `deliveryAt + duration` → the contract has hard- + * coded the window closed; the position is permanently + * stuck open from a settlement standpoint. + */ + private logRecoverableRevert( + revert: RecoverableRevert, + positionId: Hex, + blameSeller: boolean, + ): void { + if (revert === "PositionNotExists") { + this.logger.info( + { positionId, revert }, + "delivery: position already closed by someone else — dropping from index", + ); + return; + } + if (revert === "OnlyValidatorOrPositionParticipant" || revert === "PositionDeliveryExpired") { + const key = `${revert}:${positionId}`; + if (this.warned.has(key)) { + this.logger.debug( + { positionId, blameSeller, revert }, + "delivery: closeDelivery skipped (already-reported recoverable revert)", + ); + return; + } + this.warned.add(key); + const message = + revert === "OnlyValidatorOrPositionParticipant" + ? "delivery: closeDelivery rejected — keeper signer is not Futures.validatorAddress(); position will not be settled until LIQUIDATOR_PRIVATE_KEY is rotated or the position participant calls closeDelivery" + : "delivery: closeDelivery rejected — settlement window already expired; position is permanently stuck open and can no longer be cash-settled by the contract"; + this.logger.error( + { positionId, blameSeller, revert, signer: this.chain.account.address }, + message, + ); + return; + } + // PositionDeliveryNotStartedYet, OracleStale, InvalidOracle — sweep retries. + this.logger.debug( + { positionId, blameSeller, revert }, + "delivery: closeDelivery skipped (transient revert, will retry)", + ); + } + + /** + * Fire-and-forget timer at `deliveryAt + settleDelay`. If the time has + * already passed we still schedule a 0ms timer rather than calling + * `settle()` synchronously — keeps the log-handler hot path non-blocking + * and lets the periodic sweep idempotently retry on failure. + * + * `setTimeout` is bounded at ~24.8 days (int32 ms). Positions further out + * than that fall through to the periodic sweep — a daily-ish settlement + * cadence is far below that ceiling, so this only matters for synthetic + * test fixtures and far-future markets. + */ + private scheduleTimer(pos: TrackedPosition): void { + const existing = this.timers.get(pos.positionId); + if (existing !== undefined) clearTimeout(existing); + + const targetMs = Number(pos.deliveryAt) * 1000 + this.config.delivery.settleDelayMs; + const delayMs = Math.max(0, targetMs - Date.now()); + if (delayMs > MAX_TIMEOUT_MS) { + // Out of `setTimeout`'s safe range — let the sweep handle it. + return; + } + // Kick a sweep rather than calling `settle` directly. When many + // positions share the same `deliveryAt` (typical for a single-trader + // book), all their timers fire on the same tick — routing through + // sweep coalesces them into one batched `Futures.multicall` tx + // instead of N serial single-id txs racing for the next nonce. + const timer = setTimeout(() => { + void this.sweep().catch((err) => { + this.logger.error({ err, positionId: pos.positionId }, "delivery: timer-fired sweep threw"); + }); + }, delayMs); + // Don't keep the event loop alive solely for delivery timers — the + // process should exit cleanly when other components shut down. + if (typeof timer.unref === "function") timer.unref(); + this.timers.set(pos.positionId, timer); + } +} + +interface TrackedPosition { + positionId: Hex; + deliveryAt: bigint; + seller: Address; + buyer: Address; +} + +/** `setTimeout`'s int32 ms ceiling — values above are clamped silently to 1ms. */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** Reverts the module treats as "skip this attempt" rather than fatal. */ +type RecoverableRevert = + | "PositionNotExists" + | "PositionDeliveryNotStartedYet" + | "PositionDeliveryExpired" + | "OnlyValidatorOrPositionParticipant" + // Hashprice oracle hasn't ticked within `MAX_ORACLE_STALENESS` (1h). The + // periodic sweep keeps the position queued; the next attempt succeeds as + // soon as the oracle posts a fresh round. + | "OracleStale" + // Oracle returned a non-positive answer — same retry semantics. + | "InvalidOracle"; + +const RECOVERABLE_REVERTS = new Set([ + "PositionNotExists", + "PositionDeliveryNotStartedYet", + "PositionDeliveryExpired", + "OnlyValidatorOrPositionParticipant", + "OracleStale", + "InvalidOracle", +]); + +function decodeRecoverableRevert(err: unknown): RecoverableRevert | 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 as RecoverableRevert) + ? (name as RecoverableRevert) + : undefined; +} + +/** + * Tx-submission errors that mean "the broadcast didn't take, try again + * next sweep" rather than "the call would revert". We treat these as + * recoverable so a transient mempool / nonce / RPC issue doesn't crash + * the keeper via unhandled rejection on a setTimeout-fired path. + * + * Patterns we've actually seen in production logs (all `code: -32000` + * from Alchemy / Geth-flavoured nodes): + * - "replacement transaction underpriced" — same nonce already in + * mempool (e.g. concurrent manual `cast send`, or stale tx from a + * previous keeper run) + * - "nonce too low" — node just reflected the previous tx, our cached + * nonce is stale + * - "already known" — same tx hash already pending + * - "transaction underpriced" — new tx below current minGasPrice + * - generic timeout / 5xx / network errors + * + * Match by message substring because viem flattens RPC errors into + * `BaseError.shortMessage` / `details` and there's no stable code we can + * key off of across providers. + */ +function isTransientTxError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const haystack = `${err.message ?? ""} ${(err as { details?: string }).details ?? ""} ${ + (err as { shortMessage?: string }).shortMessage ?? "" + }`.toLowerCase(); + return ( + haystack.includes("replacement transaction underpriced") || + haystack.includes("transaction underpriced") || + haystack.includes("nonce too low") || + haystack.includes("already known") || + haystack.includes("known transaction") || + haystack.includes("could not coalesce") || // node-side mempool flap + haystack.includes("timeout") || + haystack.includes("econnreset") || + haystack.includes("etimedout") || + haystack.includes("socket hang up") + ); +} + +export const __testing = { decodeRecoverableRevert, isTransientTxError }; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 85fac82..40ff3fa 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,5 +1,6 @@ import pino from "pino"; import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { serializeError } from "../../market-maker/src/core/errSerializer.ts"; import { loadConfig } from "./config.ts"; import { createChain } from "./chain.ts"; import { ParticipantTracker } from "./discovery/tracker.ts"; @@ -10,10 +11,14 @@ import { CoordinatorExecutor } from "./coordinator/executor.ts"; 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"; +import { EthUsdFeed } from "./oracle/ethUsdFeed.ts"; import { PredictiveCoordinator } from "./predict/coordinator.ts"; +import { DeliveryCoordinator } from "./delivery/coordinator.ts"; import type { Venue } from "./venues/types.ts"; /** @@ -36,11 +41,13 @@ import type { Venue } from "./venues/types.ts"; */ async function main(): Promise { const config = loadConfig(); - const logger = pino({ level: config.runtime.logLevel }); + const logger = pino({ + level: config.runtime.logLevel, + serializers: { err: serializeError }, + }); logger.info( { - network: config.chain.network, perps: config.perps.address, futures: config.futures.address, vault: config.vault.address, @@ -66,9 +73,25 @@ async function main(): Promise { }); logger.info({ tokenDecimals }, "Collateral token decimals"); + // Optional ETH/USD Chainlink feed for `gasCostUsd` enrichment on every + // confirmed-tx log. Built only when the operator has configured an + // aggregator address — when unset, every tx log still gets `gasUsed` + // and `gasCostEth` but skips the USD field. Refresh cadence is fixed + // at 60s because this is a logging-only price (drift of a minute is + // immaterial when the consumer is a 4-a.m. operator scanning logs). + const ethUsdFeed = + config.oracle.ethUsdcFeedAddress !== undefined + ? new EthUsdFeed(chain, config.oracle.ethUsdcFeedAddress, logger, 60_000) + : undefined; + if (ethUsdFeed === undefined) { + logger.info( + "ETH_USD_FEED_ADDRESS unset — confirmed-tx logs will include gasCostEth but skip gasCostUsd", + ); + } + const venues: Venue[] = [ - new PerpsVenue(chain, config, logger), - new FuturesVenue(chain, config, logger), + new PerpsVenue(chain, config, logger, ethUsdFeed), + new FuturesVenue(chain, config, logger, ethUsdFeed), ]; const notifier = new Notifier(config, logger); @@ -76,7 +99,15 @@ async function main(): Promise { const queue = new CoordinatorQueue(); const planner = new Planner(chain, config, venues, logger); const executor = new CoordinatorExecutor(config, queue, planner, logger); - const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, logger); + const scheduler = new Scheduler( + chain, + config, + tracker, + queue, + executor, + notifier, + logger, + ); // Predictive layer: subscribes to BTC/USDC AnswerUpdated events, reads // the current HashpriceUSDC value, and pre-computes per-user liquidation @@ -94,18 +125,73 @@ async function main(): Promise { notifier, ); - const health = new Healthcheck(config, tracker, executor, queue, logger, predictor, priceFeed); + const health = new Healthcheck( + config, + chain.account.address, + tracker, + executor, + queue, + logger, + predictor, + priceFeed, + ); let webhookIngester: WebhookIngester | undefined; if (config.chain.discoveryMode !== "events") { webhookIngester = new WebhookIngester(config, tracker, logger); } + // Optional: cash-settle futures positions at their delivery date. Off by + // default — only useful when the keeper signer is the Futures contract's + // configured `validatorAddress`. See `delivery/coordinator.ts` for the + // full authorization story. + let deliveryCoordinator: DeliveryCoordinator | undefined; + if (config.delivery.enabled) { + deliveryCoordinator = new DeliveryCoordinator( + chain, + config, + logger, + ethUsdFeed, + ); + } + + // Always-on gas-balance monitor on the keeper signer. Logs INFO with + // current balance every tick (default 5 min), and escalates to WARN / + // ERROR below the configured low / critical thresholds. Built outside + // the delivery / executor coordinators because every tx-sending + // module shares this same wallet — the monitor is a cross-cutting + // 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 `deliveryAt` 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 // AccountHealth snapshot — that lives in the scheduler.) - tracker.onAdded(() => executor.kick()); + tracker.onAdded((user) => { + executor.kick(); + // View-based discovery: every newly-tracked user has their futures + // positions read directly from chain storage. Independent of the + // log-backfill pipeline, so it survives RPC providers that cap + // `eth_getLogs` block ranges (Alchemy free tier = 10 blocks). Without + // this hook a position created before keeper boot would only ever be + // settled if log backfill happened to find its `PositionCreated` + // event, which is unreliable on rate-limited RPCs. + if (deliveryCoordinator !== undefined) { + void deliveryCoordinator.indexUserPositions(user); + } + }); // ── Graceful shutdown ───────────────────────────────────────────────── // Predictor / priceFeed stop before the tracker so their listeners @@ -119,6 +205,10 @@ async function main(): Promise { scheduler.stop(); predictor.stop(); priceFeed.stop(); + balanceMonitor.stop(); + ethUsdFeed?.stop(); + outdatedOrderSweeper?.stop(); + deliveryCoordinator?.stop(); await executor.stop(); if (webhookIngester !== undefined) await webhookIngester.stop(); tracker.stop(); @@ -132,12 +222,21 @@ async function main(): Promise { // a baseline before the first tracker event fires. Predictor next so its // tracker hooks are in place before tracker.start() flushes any backlog. await priceFeed.start(); + // ETH/USD feed primes cheaply (one read) and is logging-only — start + // alongside the other oracle feeds so the very first tx after boot + // already has a `gasCostUsd` value rather than waiting a tick. + if (ethUsdFeed !== undefined) await ethUsdFeed.start(); await predictor.start(); await tracker.start(); if (webhookIngester !== undefined) await webhookIngester.start(); + if (deliveryCoordinator !== undefined) await deliveryCoordinator.start(); await executor.start(); scheduler.start(); health.start(); + // Eager initial check (logs the boot-time balance) + interval polling. + // Started after the venues so a startup failure earlier doesn't leave + // a phantom monitor running. + await balanceMonitor.start(); // Pull initial state so the first sweep tick has something to chew on // instead of waiting on event traffic. Backfill scans the same discovery @@ -148,13 +247,65 @@ async function main(): Promise { // No backfill anchor → forward-only (only safe with webhook discovery or // a prior keeper that's already populated the set out-of-band). if (config.chain.backfillFromBlock !== undefined) { - await tracker.backfill(config.chain.backfillFromBlock, config.chain.backfillChunkSize); + await tracker.backfill( + config.chain.backfillFromBlock, + config.chain.backfillChunkSize, + ); } else { logger.warn( "BACKFILL_FROM_BLOCK unset — skipping historical scan; cold-start may miss participants until they next emit an event", ); } + if (deliveryCoordinator !== undefined) { + // Seed the delivery index from contract storage rather than logs. + // `tracker.list()` returns every user we've discovered (via webhook, + // live events, or backfill); for each we read the still-alive + // positions and schedule timers. Survives RPC providers that + // rate-limit `eth_getLogs` and is the recommended cold-start path + // for delivery — see `bootstrapFromUsers` in the coordinator. + // + // Two sources are folded in alongside the tracker: + // 1. The keeper's own signer. The validator address is also a + // legitimate participant in many deployments (see + // 0x1441…775D4 on base-sepolia: validator + buyer/seller of + // its own positions). Their positions may pre-date + // BACKFILL_FROM_BLOCK, in which case the tracker has no + // record of them — but we know the address at boot, so the + // one extra `getPositionIds` read is a free safety net. + // 2. The manual seed list (`DELIVERY_BOOTSTRAP_USERS`). Used to + // recover a known-stuck user when the tracker hasn't found + // them — typical when log backfill is failing on the + // deployed RPC, or when the user transacted before + // BACKFILL_FROM_BLOCK. + const seen = new Set(tracker.list().map((a) => a.toLowerCase())); + const seedUsers = [...tracker.list()]; + const signer = chain.account.address; + if (!seen.has(signer.toLowerCase())) { + seedUsers.push(signer); + seen.add(signer.toLowerCase()); + logger.info( + { signer }, + "delivery bootstrap: including keeper signer (not in tracker — positions may pre-date BACKFILL_FROM_BLOCK)", + ); + } + for (const u of config.delivery.bootstrapUsers) { + if (seen.has(u.toLowerCase())) continue; + seedUsers.push(u); + seen.add(u.toLowerCase()); + } + if (config.delivery.bootstrapUsers.length > 0) { + logger.info( + { count: config.delivery.bootstrapUsers.length }, + "delivery bootstrap: seeding from DELIVERY_BOOTSTRAP_USERS", + ); + } + 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" @@ -185,6 +336,8 @@ main().catch((err) => { // Fail hard so the orchestrator restarts the pod with full logs. // Using stderr directly avoids pino formatting on a logger that might not // be initialised yet (e.g. config load failure). - process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`); + process.stderr.write( + `Fatal: ${err instanceof Error ? err.stack : String(err)}\n`, + ); process.exit(1); }); diff --git a/keeper/src/oracle/ethUsdFeed.ts b/keeper/src/oracle/ethUsdFeed.ts new file mode 100644 index 0000000..787192d --- /dev/null +++ b/keeper/src/oracle/ethUsdFeed.ts @@ -0,0 +1,144 @@ +import type pino from "pino"; +import type { Address } from "viem"; +import type { Chain } from "../chain.ts"; +import { AggregatorV3Abi } from "./abi.ts"; + +/** + * Cached reader for a Chainlink ETH/USD `AggregatorProxy`. The keeper only + * uses this for cosmetic logging — converting `gasUsed * effectiveGasPrice` + * (wei) into a USD number that's readable in a dashboard at 4 a.m. without + * doing wei-math in your head. + * + * Periodic refresh rather than event-subscribed because: + * - Latency doesn't matter for log enrichment. A 1-minute stale price + * is fine when the underlying use case is "roughly how much did this + * tx cost?". + * - One `latestRoundData` read per refresh, no `watchContractEvent` to + * unwatch — keeps the surface area trivially testable and avoids + * having ANOTHER subscription on the RPC. + * + * Lifecycle is opt-in: built only when `config.oracle.ethUsdcFeedAddress` + * is set, otherwise consumers receive `undefined` and silently skip USD + * enrichment. Failures are non-fatal — a downed feed never blocks a tx + * log or crashes the keeper; the next refresh just tries again. + */ +export class EthUsdFeed { + private timer: NodeJS.Timeout | undefined; + private running = false; + /** Last read price as raw oracle units (USD per ETH, scaled by `decimals`). */ + private price: bigint | undefined; + /** Oracle decimals (typically 8 for Chainlink USD pairs). Set on first read. */ + private decimals: number | undefined; + /** Wall-clock ms of the most recent successful read. */ + private updatedAtMs: number | undefined; + + private readonly chain: Chain; + private readonly address: Address; + private readonly logger: pino.Logger; + private readonly refreshIntervalMs: number; + + constructor( + chain: Chain, + address: Address, + logger: pino.Logger, + refreshIntervalMs: number, + ) { + this.chain = chain; + this.address = address; + this.logger = logger.child({ component: "ethUsdFeed" }); + this.refreshIntervalMs = refreshIntervalMs; + } + + /** + * Primes the cache via one eager read so the first tx log after boot + * has a price (avoids "first tx is the only one missing gasCostUsd"), + * then schedules periodic refreshes. Idempotent. + */ + async start(): Promise { + if (this.running) return; + this.running = true; + await this.refresh(); + this.timer = setInterval(() => { + void this.refresh(); + }, this.refreshIntervalMs); + // Don't keep the event loop alive for a logging-only refresh — the + // keeper's other timers / subscriptions are what pin the process. + 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; + } + } + + /** + * Convert a wei amount to USD using the most-recent price. + * Returns `undefined` when the feed hasn't successfully read yet, + * letting callers cleanly skip the USD log field. + * + * Floating-point at the boundary is deliberate: we're producing a + * log string ("$0.0023"), not doing accounting. bigint USD would + * either lose precision (round to cents) or surface confusing + * units (`123456` micro-USD). + */ + weiToUsd(weiAmount: bigint): number | undefined { + if (this.price === undefined || this.decimals === undefined) return undefined; + // usd = wei * priceUsdPerEth / 1e18 / 10^decimals + // Do the integer scaling in bigint to avoid wei overflow, then + // promote to number for the final fractional value. + const denom = 10n ** (18n + BigInt(this.decimals)); + // Multiply numerator by 1e8 for ~8 decimal places of fractional USD, + // then divide by 1e8 in float. Keeps gasCostUsd resolvable down to + // micro-cents — relevant on cheap L2s where tx cost is well below $0.01. + const scaled = (weiAmount * this.price * 100_000_000n) / denom; + return Number(scaled) / 100_000_000; + } + + /** Latest known price in raw oracle units; `undefined` until first successful read. */ + current(): bigint | undefined { + return this.price; + } + + /** Most-recent successful read time (ms-since-epoch); `undefined` until first read. */ + updatedAt(): number | undefined { + return this.updatedAtMs; + } + + /** + * Single read of `latestRoundData` + (first call only) `decimals`. + * Public so tests can drive a deterministic refresh, and so any + * caller that needs a guaranteed-fresh price (e.g. an integration + * test) can force one without waiting for the next interval tick. + */ + async refresh(): Promise { + try { + if (this.decimals === undefined) { + this.decimals = (await this.chain.publicClient.readContract({ + address: this.address, + abi: AggregatorV3Abi, + functionName: "decimals", + })) as number; + } + const data = (await this.chain.publicClient.readContract({ + address: this.address, + abi: AggregatorV3Abi, + functionName: "latestRoundData", + })) as readonly [bigint, bigint, bigint, bigint, bigint]; + const answer = data[1]; + if (answer <= 0n) { + this.logger.warn({ answer }, "ETH/USD feed returned non-positive answer — keeping previous"); + return; + } + this.price = answer; + this.updatedAtMs = Date.now(); + } catch (err) { + // RPC blip or stale node — keep the previous price (it's only + // used for logging enrichment) and try again next tick. + this.logger.warn({ err }, "ETH/USD feed refresh failed — keeping previous price"); + } + } +} diff --git a/keeper/src/predict/coordinator.ts b/keeper/src/predict/coordinator.ts index 42382cb..5ec133e 100644 --- a/keeper/src/predict/coordinator.ts +++ b/keeper/src/predict/coordinator.ts @@ -147,6 +147,48 @@ export class PredictiveCoordinator { return this.inflightRebuilds.size; } + /** Addresses with an in-flight snapshot rebuild right now. */ + inflightUsers(): Address[] { + return Array.from(this.inflightRebuilds.keys()); + } + + /** + * One entry per user the predictor is watching. Combines the three + * indices (liquidation / warn-alert / critical-alert) into a single + * per-user record so consumers see "for user X, here are all the price + * levels that trigger something" instead of three separate rosters. + * + * `down` = price falling to/through the threshold trips the action; + * `up` = price rising to/through it trips the action; + * `null` = the solver returned no threshold on that side (the user is + * structurally safe in that direction at any plausible price, + * OR is already past the threshold — see `solve.ts` for the + * "already past" short-circuit). + * + * Bigint thresholds are stringified — JSON has no native bigint and the + * ops dashboards downstream need string-comparable values anyway. + */ + thresholds(): PredictedThresholds[] { + const users = new Set
([ + ...this.liqIndex.users(), + ...this.warnIndex.users(), + ...this.critIndex.users(), + ]); + const out: PredictedThresholds[] = []; + for (const user of users) { + const liq = this.liqIndex.get(user); + const warn = this.warnIndex.get(user); + const crit = this.critIndex.get(user); + out.push({ + user, + liq: priceSides(liq?.liqDown, liq?.liqUp), + warn: priceSides(warn?.liqDown, warn?.liqUp), + crit: priceSides(crit?.liqDown, crit?.liqUp), + }); + } + return out; + } + /** * Await all currently in-flight rebuilds. Used at startup so we can * declare "ready" only after the startup backfill has populated @@ -329,6 +371,31 @@ export class PredictiveCoordinator { } } +/** + * One row of `thresholds()`. Three triggers per user (liquidation / + * warn-alert / critical-alert), each with a `down` and `up` price (or + * `null` if not crossable on that side). + */ +export interface PredictedThresholds { + user: Address; + liq: ThresholdSides; + warn: ThresholdSides; + crit: ThresholdSides; +} + +/** `down`/`up` price levels for one trigger, JSON-friendly strings. */ +export interface ThresholdSides { + down: string | null; + up: string | null; +} + +function priceSides(down: bigint | undefined, up: bigint | undefined): ThresholdSides { + return { + down: down === undefined ? null : down.toString(), + up: up === undefined ? null : up.toString(), + }; +} + /** * Absolute price-move magnitude in basis points (1bp = 0.01%). Computed * relative to `prev` — "how much did the price move as a fraction of where diff --git a/keeper/src/predict/predictiveIndex.ts b/keeper/src/predict/predictiveIndex.ts index 0234d57..dac68c7 100644 --- a/keeper/src/predict/predictiveIndex.ts +++ b/keeper/src/predict/predictiveIndex.ts @@ -76,6 +76,11 @@ export class PredictiveIndex { return this.byUser.size; } + /** Addresses of every user with at least one defined threshold. */ + users(): Address[] { + return Array.from(this.byUser.keys()); + } + /** * Find every user whose threshold was crossed by a price move from * `prev` to `next`. Both endpoints are inclusive of the boundary — diff --git a/keeper/src/runtime/balanceMonitor.ts b/keeper/src/runtime/balanceMonitor.ts new file mode 100644 index 0000000..cf4b5f2 --- /dev/null +++ b/keeper/src/runtime/balanceMonitor.ts @@ -0,0 +1,118 @@ +import { formatEther } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; + +/** + * Periodically polls the keeper signer's native gas-token balance and + * surfaces it through the same logger every other module uses, so an + * operator who watches the keeper's tail (or pipes it to Loki / CloudWatch) + * has a clear "is the wallet about to run out of gas?" signal without + * having to hop into a block explorer. + * + * Severity ladder (mirrors how dashboards usually classify gas alerts): + * + * - balance >= low → INFO ("balance OK") — single source of truth + * for "the keeper saw N gwei at time T", useful for graphing. + * - balance < low → WARN ("balance low") — operator should top up + * within the next few hours; nothing is failing yet. + * - balance < crit → ERROR ("balance critical") — next handful of + * liquidations / settlements will likely revert with + * "insufficient funds for gas". Page on-call. + * + * Defaults are sized for Base sepolia / mainnet at ~current gas: + * low = 10 mETH (≈ a few hundred mid-sized txs of headroom) + * critical = 1 mETH (≈ a few txs left, top up NOW) + * + * The monitor never throws — RPC blips are logged at warn and the next + * tick retries. Stop is idempotent so the same shutdown sequence used + * for every other component works. + */ +export class BalanceMonitor { + private timer: NodeJS.Timeout | undefined; + private running = false; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + + constructor(chain: Chain, config: Config, logger: pino.Logger) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ component: "balanceMonitor" }); + } + + /** Reads the balance once, logs at the appropriate level, and returns it. */ + async check(): Promise { + let balance: bigint; + try { + balance = await this.chain.publicClient.getBalance({ + address: this.chain.account.address, + }); + } catch (err) { + // RPC hiccup — don't crash the keeper, the next tick will retry. + // Log warn (not error) because a single failed read isn't itself an + // operational issue; persistent failures will keep firing this log + // and an `eth_getBalance` outage is usually visible in other module + // logs anyway. + this.logger.warn( + { err, address: this.chain.account.address }, + "balance check failed — will retry next tick", + ); + return undefined; + } + + const ctx = { + address: this.chain.account.address, + balanceWei: balance.toString(), + balanceEth: formatEther(balance), + lowThresholdEth: formatEther(this.config.runtime.balanceLowWei), + criticalThresholdEth: formatEther(this.config.runtime.balanceCriticalWei), + }; + + if (balance < this.config.runtime.balanceCriticalWei) { + this.logger.error( + ctx, + "keeper signer gas balance CRITICAL — top up now or settlements/liquidations will start reverting with insufficient funds", + ); + } else if (balance < this.config.runtime.balanceLowWei) { + this.logger.warn( + ctx, + "keeper signer gas balance low — top up soon", + ); + } else { + this.logger.info(ctx, "keeper signer gas balance OK"); + } + return balance; + } + + /** + * Performs an immediate check, then schedules periodic polls at + * `runtime.balanceCheckIntervalMs`. Idempotent — a second call is a + * no-op so callers don't need to guard against double-start (matches + * the pattern used by every other long-running component). + */ + async start(): Promise { + if (this.running) return; + this.running = true; + // Eager check at boot so an empty wallet is loud immediately, not + // one full interval later (default 5 min — too long to wait for the + // first signal during a deploy). + await this.check(); + this.timer = setInterval(() => { + void this.check(); + }, this.config.runtime.balanceCheckIntervalMs); + // Don't keep the process alive solely for the balance-poll loop — + // shutdown should proceed even if this timer is mid-cycle. + 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; + } + } +} diff --git a/keeper/src/runtime/healthcheck.ts b/keeper/src/runtime/healthcheck.ts index f1de857..0f602af 100644 --- a/keeper/src/runtime/healthcheck.ts +++ b/keeper/src/runtime/healthcheck.ts @@ -1,18 +1,24 @@ import { createServer, type Server } from "node:http"; +import type { Address } from "viem"; import type pino from "pino"; import type { Config } from "../config.ts"; import type { CoordinatorExecutor } from "../coordinator/executor.ts"; import type { CoordinatorQueue } from "../coordinator/queue.ts"; import type { ParticipantTracker } from "../discovery/tracker.ts"; import type { PriceFeed } from "../oracle/priceFeed.ts"; -import type { PredictiveCoordinator } from "../predict/coordinator.ts"; +import type { PredictedThresholds, PredictiveCoordinator } from "../predict/coordinator.ts"; /** * Health and metrics surface for the keeper. * * GET /health liveness probe (200 ok / 503 degraded). Body holds the - * same metrics as /metrics for convenience. + * full snapshot — counters AND per-user address lists + * (`trackedUsers`, `predictedUsers`, `predictorInflight`, + * `underwater`) — so a single `curl :3000/health | jq` + * tells ops everything the keeper currently knows. * GET /metrics Prometheus-text exposition of keeper-internal counters. + * Address lists are reduced to their `length` (gauge) + * here so we don't blow up Prometheus cardinality. * * Health flips to 503 when the executor isn't running (event watcher * silently dropped, executor stopped) so the orchestrator (k8s, ECS) @@ -26,6 +32,7 @@ export class Healthcheck { private server: Server | undefined; private readonly config: Config; + private readonly signerAddress: Address; private readonly tracker: ParticipantTracker; private readonly executor: CoordinatorExecutor; private readonly queue: CoordinatorQueue; @@ -35,6 +42,7 @@ export class Healthcheck { constructor( config: Config, + signerAddress: Address, tracker: ParticipantTracker, executor: CoordinatorExecutor, queue: CoordinatorQueue, @@ -43,6 +51,7 @@ export class Healthcheck { priceFeed?: PriceFeed, ) { this.config = config; + this.signerAddress = signerAddress; this.tracker = tracker; this.executor = executor; this.queue = queue; @@ -51,17 +60,69 @@ export class Healthcheck { this.logger = logger.child({ component: "healthcheck" }); } - /** Snapshot of every observable counter the keeper exposes. */ - snapshot(): Record { + /** + * Static identity of this keeper instance: network, signer, contract + * addresses, and operating-mode flags. Returned as strings so it can be + * rendered as Prometheus labels (`keeper_info{…} 1`) and as a JSON block + * on `/health` for ops dashboards. + */ + info(): Record { + return { + network: this.config.chain.network, + discoveryMode: this.config.chain.discoveryMode, + dryRun: String(this.config.keeper.dryRun), + signer: this.signerAddress, + vault: this.config.vault.address, + perps: this.config.perps.address, + futures: this.config.futures.address, + pme: this.config.pme.address, + hashpriceUsdcFeed: this.config.oracle.hashpriceUsdcAddress, + btcUsdcFeed: this.config.oracle.btcUsdcFeedAddress, + }; + } + + /** + * Snapshot of every observable counter the keeper exposes. + * + * - `trackedUsers`: every address the keeper monitors, full list. + * - `underwater`: queue contents (`mmSurplus < 0`), head-first. + * - `predictedThresholds`: one row per user the predictor is watching, + * with `liq` / `warn` / `crit` price levels combined so consumers + * see all triggers for a user in one place. Being listed here means + * "we've solved future thresholds for this user", not "this user is + * currently in warn/critical state" — current state is on-chain + * `imUtilization`, owned by the alert path. + * - `predictorInflight`: users with an in-flight predictive rebuild. + * + * Predictor-derived arrays are empty when the predictor isn't wired. + */ + snapshot(): Record< + string, + | number + | string + | readonly Address[] + | readonly UnderwaterEntry[] + | readonly PredictedThresholds[] + | null + > { + // Surface the head of the queue — the single most diagnostic number + // for a liquidator (how underwater is the worst account right now, + // and which one is it). `mmDeficit` is `|mmSurplus|` because the + // queue only ever holds underwater accounts (`mmSurplus < 0`). + const head = this.queue.peek(); return { executorRunning: this.executor.isRunning() ? 1 : 0, - trackedUsers: this.tracker.size(), + trackedUsers: this.tracker.list(), inflight: this.executor.inflightCount(), queueDepth: this.queue.size(), - predictedUsers: this.predictor?.size() ?? 0, - predictedWarnUsers: this.predictor?.warnSize() ?? 0, - predictedCritUsers: this.predictor?.critSize() ?? 0, - predictorInflight: this.predictor?.inflight() ?? 0, + queueHeadMmDeficit: head === undefined ? 0 : (-head.mmSurplus).toString(), + queueHeadUser: head?.user ?? null, + underwater: this.queue.snapshot().map((h) => ({ + user: h.user, + mmDeficit: (-h.mmSurplus).toString(), + })), + predictedThresholds: this.predictor?.thresholds() ?? [], + predictorInflight: this.predictor?.inflightUsers() ?? [], currentPrice: this.priceFeed?.current()?.toString() ?? null, }; } @@ -74,6 +135,7 @@ export class Healthcheck { res.end( JSON.stringify({ status: ok ? "ok" : "degraded", + info: this.info(), ...this.snapshot(), }), ); @@ -100,13 +162,24 @@ export class Healthcheck { } /** - * Minimal Prometheus exposition. Skips the `currentPrice` line when the - * feed hasn't primed yet (Prometheus rejects non-numeric values). Each - * metric uses a `keeper_` prefix to namespace it from system metrics. + * Minimal Prometheus exposition. Each metric uses a `keeper_` prefix to + * namespace it from system metrics. Address-list snapshot fields are + * collapsed to their `length` (preserves the previous count semantics — + * `keeper_predicted_users` etc. — without exploding label cardinality). + * Identity strings (`network`, addresses, …) ride on a single + * `keeper_info{…} 1` info-style metric. */ private renderPrometheus(): string { const snap = this.snapshot(); const lines: string[] = []; + + const labels = Object.entries(this.info()) + .map(([k, v]) => `${snakeCase(k)}="${escapeLabel(v)}"`) + .join(","); + lines.push(`# HELP keeper_info Static identity of this keeper instance.`); + lines.push(`# TYPE keeper_info gauge`); + lines.push(`keeper_info{${labels}} 1`); + for (const [k, v] of Object.entries(snap)) { if (k === "currentPrice") { if (v === null) continue; @@ -115,7 +188,24 @@ export class Healthcheck { lines.push(`keeper_oracle_price_token ${v}`); continue; } + // Skip unknown values (queue empty, feed not primed, etc). + if (v === null) continue; const metric = `keeper_${snakeCase(k)}`; + // Arrays: emit length so existing dashboards (`keeper_tracked_users`, + // `keeper_predicted_users`, …) keep working as count gauges. The + // full address list lives in `/health` only. + if (Array.isArray(v)) { + lines.push(`# TYPE ${metric} gauge`); + lines.push(`${metric} ${v.length}`); + continue; + } + // String values that aren't pure integers are address-shaped or + // similar identifiers — emit as a labelled info gauge. + if (typeof v === "string" && !/^-?\d+$/.test(v)) { + lines.push(`# TYPE ${metric}_info gauge`); + lines.push(`${metric}_info{value="${escapeLabel(v)}"} 1`); + continue; + } lines.push(`# TYPE ${metric} gauge`); lines.push(`${metric} ${v}`); } @@ -123,6 +213,18 @@ export class Healthcheck { } } +/** Single underwater-account entry returned by `snapshot().underwater`. */ +interface UnderwaterEntry { + user: Address; + /** `|mmSurplus|` as a decimal string — bigints don't round-trip JSON. */ + mmDeficit: string; +} + function snakeCase(camel: string): string { return camel.replace(/([A-Z])/g, "_$1").toLowerCase(); } + +/** Escape backslashes, double quotes and newlines per the Prometheus text spec. */ +function escapeLabel(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts new file mode 100644 index 0000000..9840e4a --- /dev/null +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -0,0 +1,368 @@ +import { + BaseError, + ContractFunctionRevertedError, + encodeFunctionData, + type Address, + type Hex, +} from "viem"; +import type pino from "pino"; +import { FuturesAbi } from "futures-marketplace/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 `getOrderIds(user)` — empty? skip + * 2. multicall `getOrderById(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; + deliveryAt: 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 + * `deliveryAt < 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: "getOrderIds", + args: [user], + })) as readonly Hex[]; + } catch (err) { + this.logger.warn( + { err, user }, + "getOrderIds failed — skipping user this sweep", + ); + continue; + } + if (orderIds.length === 0) continue; + + let orders: ReadonlyArray<{ deliveryAt: bigint }>; + try { + orders = (await this.chain.publicClient.multicall({ + contracts: orderIds.map((id) => ({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getOrderById" as const, + args: [id] as const, + })), + allowFailure: false, + })) as ReadonlyArray<{ deliveryAt: bigint }>; + } catch (err) { + this.logger.warn( + { err, user, orderCount: orderIds.length }, + "multicall(getOrderById) 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: `deliveryAt >= block.timestamp` reverts + // `OrderNotExpired`. Use strict-less-than here so we don't broadcast + // a tx in the very-edge case `deliveryAt == blockTimestamp` (next + // block will satisfy it cleanly). + if (order.deliveryAt < blockTimestamp) { + expired.push({ user, orderId, deliveryAt: order.deliveryAt }); + } + } + } + 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[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[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/tx/gasCost.ts b/keeper/src/tx/gasCost.ts new file mode 100644 index 0000000..948e3cb --- /dev/null +++ b/keeper/src/tx/gasCost.ts @@ -0,0 +1,68 @@ +import { formatEther, formatGwei, type TransactionReceipt } from "viem"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; + +/** + * Flat shape spread into every "tx confirmed" log line so operators can + * answer "how much did that cost?" without doing wei-math or hopping into + * a block explorer. + * + * - `gasUsed` : number (gas units consumed) + * - `gasPriceGwei` : string ("1.234" — EIP-1559 effective price) + * - `gasCostEth` : string ("0.00045" — native cost on Base / mainnet) + * - `gasCostUsd` : number ($0.0023) — present only when an ETH/USD + * feed is wired AND has a price; absent otherwise + * so log search / metric extraction can distinguish + * "feed off" from "feed read zero". + * + * All fields are strings or primitives (no `bigint`) because pino's + * default JSON serializer chokes on bigints — every other tx log in this + * codebase already speaks the string convention. + */ +export interface GasCostFields { + gasUsed: number; + gasPriceGwei: string; + gasCostEth: string; + gasCostUsd?: number; +} + +/** + * Compute gas/price log fields from a confirmed tx receipt. `ethUsdFeed` + * is optional — when omitted, the USD field is dropped silently. + * + * Defensive against partial receipts: viem's `TransactionReceipt` types + * `gasUsed` / `effectiveGasPrice` as non-optional, but RPC providers + * occasionally return `null` here on freshly-mined txs. We treat + * missing values as `0n` so we never crash a tx confirmation path on a + * cosmetic field. + */ +export function formatGasCost( + receipt: Pick, + ethUsdFeed?: EthUsdFeed, +): GasCostFields { + const gasUsed = receipt.gasUsed ?? 0n; + const gasPrice = receipt.effectiveGasPrice ?? 0n; + const gasCostWei = gasUsed * gasPrice; + + const fields: GasCostFields = { + gasUsed: Number(gasUsed), + gasPriceGwei: formatGwei(gasPrice), + gasCostEth: formatEther(gasCostWei), + }; + + if (ethUsdFeed !== undefined) { + const usd = ethUsdFeed.weiToUsd(gasCostWei); + if (usd !== undefined) fields.gasCostUsd = roundUsd(usd); + } + + return fields; +} + +/** + * Round USD to 6 decimal places so micro-cent precision survives in + * `pino`'s default JSON output without printing pages of trailing + * floating-point garbage. Six places resolves down to $0.000001 — + * enough headroom for sub-cent L2 gas costs. + */ +function roundUsd(usd: number): number { + return Math.round(usd * 1_000_000) / 1_000_000; +} diff --git a/keeper/src/tx/liquidate.ts b/keeper/src/tx/liquidate.ts index f1bcb62..328640b 100644 --- a/keeper/src/tx/liquidate.ts +++ b/keeper/src/tx/liquidate.ts @@ -10,6 +10,8 @@ import { import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; +import { formatGasCost } from "./gasCost.ts"; /** * Common shape returned by all liquidate-style calls. Either we earned a fee @@ -59,6 +61,12 @@ interface SendLiquidateOptions { * — defaults to "notLiquidatable" so the planner keeps moving. */ mapSkip?: (errorName: KnownRevert) => S; + /** + * Optional ETH/USD price source. When provided the confirmation log + * picks up a `gasCostUsd` field alongside `gasCostEth`. Always + * optional so deployments without a configured feed stay supported. + */ + ethUsdFeed?: EthUsdFeed; } /** @@ -76,7 +84,18 @@ interface SendLiquidateOptions { export async function sendLiquidate( opts: SendLiquidateOptions, ): Promise> { - const { chain, config, logger, address, abi, functionName, args, feeEventName, mapSkip } = opts; + const { + chain, + config, + logger, + address, + abi, + functionName, + args, + feeEventName, + mapSkip, + ethUsdFeed, + } = opts; // Always simulate first — this is how we surface the recoverable reverts // before we burn gas on a tx that can't possibly succeed. Viem's overloads @@ -118,7 +137,10 @@ export async function sendLiquidate( }); const feeEarned = feeEventName === null ? 0n : sumFees(abi, receipt, feeEventName); - logger.info({ functionName, args, hash, feeEarned }, "Liquidate tx confirmed"); + logger.info( + { functionName, args, hash, feeEarned, ...formatGasCost(receipt, ethUsdFeed) }, + "Liquidate tx confirmed", + ); return { feeEarned, receipt }; } diff --git a/keeper/src/tx/unstick.ts b/keeper/src/tx/unstick.ts new file mode 100644 index 0000000..827bcbf --- /dev/null +++ b/keeper/src/tx/unstick.ts @@ -0,0 +1,239 @@ +import type pino from "pino"; +import type { Hex } from "viem"; +import type { Chain } from "../chain.ts"; + +/** + * Recovery for `replacement transaction underpriced`. + * + * Scenario this fixes (the only one we've actually seen in production): + * + * 1. Keeper broadcasts tx at nonce N. The RPC accepts it into the + * mempool but the receipt poll times out (ours, viem's, or the + * RPC provider's) before we hear back. + * 2. Process restarts (deploy, crash, `pnpm dev` reload). The new + * run reads `getTransactionCount` which the provider answers from + * `latest` blockTag → returns N (the stuck tx hasn't mined yet). + * 3. New run tries to send its first tx at nonce N. Mempool already + * has one there → rejects with `replacement transaction + * underpriced` (gas was equal, not strictly higher). + * + * Without intervention this loops forever — every sweep retries with + * the same gas, every retry hits the same revert. The user has to + * manually unstick the wallet (cast a high-gas self-transfer). + * + * `unstickPendingNonces` automates that recovery: it walks every nonce + * in `[latest, pending)` and submits a 0-value self-transfer at + * aggressively-bumped gas (3× current EIP-1559 fees). Each self-transfer + * either: + * - replaces the stuck tx by using the same nonce + higher gas + * (mempool drops the original, mines our cancel instead), or + * - races the stuck tx to inclusion (whichever lands first wins; our + * subsequent retry handles "nonce too low" the same way it handles + * a successful unstick — by moving to the next pending nonce). + * + * Self-transfers cost 21k gas × bumped price each — at base-sepolia + * defaults that's a fraction of a cent per stuck nonce. Bounded loop + * with a hard cap on the number of nonces we'll cancel in one go, so a + * misreporting RPC can't drain the wallet by claiming millions of + * pending txs. + */ +const MAX_NONCES_PER_UNSTICK = 32; + +/** + * Multiplier applied to current `maxFeePerGas` / `maxPriorityFeePerGas`. + * EIP-1559 / geth requires both to be ≥ 110% of the replaced tx for the + * mempool to accept the swap. We go to 300% so we don't have to reason + * about whether the stuck tx was already at our previous estimate or + * something higher (manual `cast send`, prior unstick attempt, etc). + */ +const GAS_BUMP_MULTIPLIER = 3n; + +/** + * Walks pending nonces and submits high-gas cancellations until the + * mempool agrees `pending == latest`. Returns the number of cancellations + * actually broadcast (0 means there was nothing stuck — the original + * `replacement underpriced` was a transient state, retry will succeed). + * + * Throws only on RPC failures during the unstick itself (e.g. the + * provider is unreachable). Per-nonce errors during the cancellation + * loop are logged and skipped — `nonce too low` is expected when the + * stuck tx clears between our pending-count read and our cancel send. + */ +export async function unstickPendingNonces( + chain: Chain, + logger: pino.Logger, +): Promise { + const address = chain.account.address; + const [latestNonce, pendingNonce] = await Promise.all([ + chain.publicClient.getTransactionCount({ address, blockTag: "latest" }), + chain.publicClient.getTransactionCount({ address, blockTag: "pending" }), + ]); + + if (pendingNonce <= latestNonce) { + logger.info( + { address, latestNonce, pendingNonce }, + "unstick: no pending txs in mempool — nothing to cancel", + ); + return 0; + } + + const stuckCount = pendingNonce - latestNonce; + if (stuckCount > MAX_NONCES_PER_UNSTICK) { + // Defensive cap. Either the provider is reporting nonsense or + // someone has been hammering the wallet from outside the keeper. + // Log loud and refuse to cancel hundreds of nonces in one shot. + logger.error( + { address, latestNonce, pendingNonce, stuckCount, cap: MAX_NONCES_PER_UNSTICK }, + "unstick: refusing to cancel more than the configured cap — investigate manually before retrying", + ); + throw new Error( + `unstick refusing to process ${stuckCount} stuck nonces (cap ${MAX_NONCES_PER_UNSTICK})`, + ); + } + + // Estimate current network fees once. We use the same bumped rate + // for every cancellation in this batch — they all need to win against + // the same mempool snapshot, and re-estimating per-iteration would + // race a mining mempool. + const fees = await chain.publicClient.estimateFeesPerGas(); + const bumpedMaxFee = fees.maxFeePerGas * GAS_BUMP_MULTIPLIER; + const bumpedTip = fees.maxPriorityFeePerGas * GAS_BUMP_MULTIPLIER; + + logger.warn( + { + address, + latestNonce, + pendingNonce, + stuckCount, + bumpedMaxFee: bumpedMaxFee.toString(), + bumpedTip: bumpedTip.toString(), + }, + "unstick: cancelling stuck mempool txs to clear the way for the next broadcast", + ); + + let cancelled = 0; + for (let nonce = latestNonce; nonce < pendingNonce; nonce++) { + try { + // 0-value self-transfer: 21k gas, never reverts, evicts the + // stuck tx at this nonce by replacing it with a properly-priced + // one. We don't wait for the receipt of EACH cancel before + // sending the next — they're independent nonces, the mempool + // accepts them in parallel, and we only need to await the LAST + // one to know the wallet is clear. + const hash = await chain.walletClient.sendTransaction({ + account: chain.account, + chain: chain.walletClient.chain ?? null, + to: address, + value: 0n, + nonce, + maxFeePerGas: bumpedMaxFee, + maxPriorityFeePerGas: bumpedTip, + }); + logger.info({ nonce, hash }, "unstick: cancellation broadcast"); + cancelled++; + } catch (err) { + // `nonce too low` here means the stuck tx mined between our + // pending-count read and our cancel send. That's a happy path + // — the slot is free, no cancellation needed. Anything else + // (rate limit, malformed) we log and keep going so one bad + // nonce doesn't block the rest. + const message = err instanceof Error ? err.message.toLowerCase() : ""; + if (message.includes("nonce too low") || message.includes("already known")) { + logger.info({ nonce, err: message }, "unstick: nonce already cleared, skipping"); + continue; + } + logger.warn({ nonce, err }, "unstick: cancellation send failed — continuing with next nonce"); + } + } + + // Wait for the highest-nonce cancellation to confirm. Once that's + // mined, all lower-nonce cancellations are guaranteed mined too + // (nonce ordering), so a single waitForTransactionReceipt drains + // the entire batch. We don't have the hash readily here, so we + // poll the on-chain nonce count until it catches up. + await waitForNonceToClear(chain, logger, pendingNonce); + return cancelled; +} + +/** + * Polls `getTransactionCount({blockTag: "latest"})` until it reaches + * `targetNonce`, indicating every pending tx has either mined or been + * cancelled. Bounded by `timeoutMs` so a stalled mempool can't hang + * the calling sweep indefinitely. + */ +async function waitForNonceToClear( + chain: Chain, + logger: pino.Logger, + targetNonce: number, + timeoutMs = 60_000, + pollMs = 2_000, +): Promise { + const address = chain.account.address; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const current = await chain.publicClient.getTransactionCount({ + address, + blockTag: "latest", + }); + if (current >= targetNonce) { + logger.info({ address, latestNonce: current }, "unstick: mempool drained"); + return; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + logger.warn( + { address, targetNonce, timeoutMs }, + "unstick: timeout waiting for mempool to drain — proceeding anyway, retry may still hit replacement-underpriced", + ); +} + +/** + * Wraps a write that may fail with `replacement transaction underpriced`. + * On that specific error, runs `unstickPendingNonces` and retries the + * write exactly once. Any other error (including a second + * `replacement-underpriced` after unstick) propagates. + * + * Use this for any write that talks to the keeper's shared signer. + * Safe to nest because the inner write is wrapped in the same recovery + * — the second attempt either succeeds or surfaces the underlying + * problem (e.g. funds, gas estimation) without infinite recursion. + */ +export async function withUnstickRetry( + chain: Chain, + logger: pino.Logger, + write: () => Promise, +): Promise { + try { + return await write(); + } catch (err) { + if (!isReplacementUnderpriced(err)) throw err; + logger.warn( + { err }, + "withUnstickRetry: hit `replacement transaction underpriced` — running unstick before retrying", + ); + await unstickPendingNonces(chain, logger); + // Single retry. If the second attempt also hits replacement- + // underpriced, something is structurally wrong (RPC reporting bad + // nonces, another writer hammering the same key) — let it surface + // rather than masking with infinite retries. + return await write(); + } +} + +/** + * Identifies the specific viem / RPC error shape that means "your + * intended nonce is already pending in the mempool". Match by message + * substring because the error code (-32000) is shared across many + * provider-side rejections and viem does not give us a stable + * discriminator. + */ +export function isReplacementUnderpriced(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const haystack = `${err.message ?? ""} ${(err as { details?: string }).details ?? ""} ${ + (err as { shortMessage?: string }).shortMessage ?? "" + }`.toLowerCase(); + return ( + haystack.includes("replacement transaction underpriced") || + haystack.includes("transaction underpriced") + ); +} diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 073d396..0212cb0 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -4,6 +4,7 @@ import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import { FuturesAbi } from "futures-marketplace/Futures.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, LiquidatePositionOutcome, @@ -28,12 +29,18 @@ export class FuturesVenue implements Venue { private readonly chain: Chain; private readonly config: Config; private readonly logger: pino.Logger; + private readonly ethUsdFeed: EthUsdFeed | undefined; private deliveryDurationDays: bigint | undefined; - constructor(chain: Chain, config: Config, logger: pino.Logger) { + constructor(chain: Chain, config: Config, logger: pino.Logger, ethUsdFeed?: EthUsdFeed) { this.chain = chain; this.config = config; this.logger = logger.child({ venue: "futures" }); + // Optional — when present every confirmed-tx log gets `gasCostUsd` + // alongside `gasCostEth`. Wiring keeps the field absent (rather than + // zero) when the feed is unset so log search can distinguish "feed + // off" from a literal zero-cost tx. + this.ethUsdFeed = ethUsdFeed; } marketLabel(marketId: MarketId): string { @@ -136,6 +143,7 @@ export class FuturesVenue implements Venue { functionName: "liquidateOrders", args: [user], feeEventName: "OrderLiquidated", + ethUsdFeed: this.ethUsdFeed, }); return "skipped" in result ? { skipped: "notLiquidatable" } : { feeEarned: result.feeEarned }; @@ -155,6 +163,7 @@ export class FuturesVenue implements Venue { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; return "notLiquidatable"; }, + ethUsdFeed: this.ethUsdFeed, }); return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index da7617b..20128a5 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -13,6 +13,8 @@ import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import { HashPowerPerpsDEXAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; +import { formatGasCost } from "../tx/gasCost.ts"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, LiquidatePositionOutcome, @@ -33,11 +35,15 @@ export class PerpsVenue implements Venue { private readonly chain: Chain; private readonly config: Config; private readonly logger: pino.Logger; + private readonly ethUsdFeed: EthUsdFeed | undefined; - constructor(chain: Chain, config: Config, logger: pino.Logger) { + constructor(chain: Chain, config: Config, logger: pino.Logger, ethUsdFeed?: EthUsdFeed) { this.chain = chain; this.config = config; this.logger = logger.child({ venue: "perps" }); + // See note in FuturesVenue — optional ETH/USD feed for `gasCostUsd` + // enrichment on confirmed-tx logs. + this.ethUsdFeed = ethUsdFeed; } marketLabel(_marketId: MarketId): string { @@ -185,7 +191,7 @@ export class PerpsVenue implements Venue { const feeEarned = sumOrderLiquidatedFees(receipt); const ordersClosed = countSuccesses(successes); this.logger.info( - { user, hash, ordersClosed, feeEarned }, + { user, hash, ordersClosed, feeEarned, ...formatGasCost(receipt, this.ethUsdFeed) }, "perps batch liquidate confirmed", ); return { feeEarned }; @@ -208,6 +214,7 @@ export class PerpsVenue implements Venue { // it the same way. return "notLiquidatable"; }, + ethUsdFeed: this.ethUsdFeed, }); return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts new file mode 100644 index 0000000..9870c1a --- /dev/null +++ b/keeper/tests/delivery/coordinator.test.ts @@ -0,0 +1,1056 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + BaseError, + ContractFunctionRevertedError, + parseAbi, + type Address, + type Hex, + type Log, + type TransactionReceipt, +} from "viem"; +import type pino from "pino"; +import { DeliveryCoordinator, __testing } from "../../src/delivery/coordinator.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; +const VALIDATOR = "0x0000000000000000000000000000000000005a1d" as Address; +const SELLER = "0x0000000000000000000000000000000000005e11" as Address; +const BUYER = "0x0000000000000000000000000000000000000b0b" as Address; + +const POSITION_A: Hex = `0x${"a".repeat(64)}`; +const POSITION_B: Hex = `0x${"b".repeat(64)}`; +const POSITION_C: Hex = `0x${"c".repeat(64)}`; + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +interface LogCall { + level: "debug" | "info" | "warn" | "error"; + obj: Record; + msg: string; +} + +/** Capturing logger for tests that assert on log severity / messages. */ +function makeRecordingLogger(): { logger: pino.Logger; calls: LogCall[] } { + const calls: LogCall[] = []; + const make = (level: LogCall["level"]) => (obj: Record, msg?: string) => { + if (typeof obj === "string") calls.push({ level, obj: {}, msg: obj }); + else calls.push({ level, obj, msg: msg ?? "" }); + }; + const logger = { + child: () => logger, + debug: make("debug"), + info: make("info"), + warn: make("warn"), + error: make("error"), + } as unknown as pino.Logger; + return { logger, calls }; +} + +function makeConfig(overrides: Partial = {}): Config { + return { + futures: { address: FUTURES }, + keeper: { dryRun: false }, + coordinator: { confirmationBlocks: 1 }, + delivery: { + enabled: true, + blameSeller: true, + sweepIntervalMs: 1_000_000, + settleDelayMs: 0, + bootstrapUsers: [], + maxBatchSize: 50, + ...overrides, + }, + } as Config; +} + +interface PositionCreatedLog { + args: { + positionId: Hex; + seller: Address; + buyer: Address; + deliveryAt: bigint; + }; +} + +interface PositionClosedLog { + args: { positionId: Hex }; +} + +function positionCreatedLog(positionId: Hex, deliveryAt: bigint): PositionCreatedLog { + return { args: { positionId, seller: SELLER, buyer: BUYER, deliveryAt } }; +} + +function positionClosedLog(positionId: Hex): PositionClosedLog { + return { args: { positionId } }; +} + +interface ChainStubOptions { + /** Fixed `block.timestamp`-style number returned by `getBlockNumber`. */ + blockNumber?: bigint; + /** Value `deliveryDurationDays` returns (uint8 → number). Defaults to 7. */ + deliveryDurationDays?: number; + /** Recorded calls to `simulateContract`. The handler is per-call so tests can vary outcomes. */ + simulate?: (args: readonly unknown[]) => { request: { ok: true } } | { error: unknown }; + writeHash?: `0x${string}`; + receipt?: TransactionReceipt; + /** Captures live event subscriptions so a test can flush manual logs into them. */ + watchers?: { + positionCreated?: (logs: readonly PositionCreatedLog[]) => void; + positionClosed?: (logs: readonly PositionClosedLog[]) => void; + }; + /** + * Historical logs returned by `getContractEvents`, keyed by event name. + * Same logs are returned for every chunk — tests use a single chunk that + * covers the whole window, so a per-chunk dispatcher is overkill here. + */ + history?: { + PositionCreated?: PositionCreatedLog[]; + PositionClosed?: PositionClosedLog[]; + }; + /** + * View-based discovery fixtures: per-user `getPositionIds` results and + * per-id `getPositionById` results, used by `bootstrapFromUsers` / + * `indexUserPositions`. Missing keys default to an empty list / + * `seller == 0` (already-closed) so a test can express "this user has + * no positions" or "this id is closed" without populating both maps. + */ + positionIdsByUser?: ReadonlyMap; + positionsById?: ReadonlyMap; + /** + * Override `Futures.validatorAddress()` returned by the chain stub. + * Defaults to `VALIDATOR` (matches the stub's signer) so existing tests + * pass `start()`'s pre-flight check transparently. + */ + validator?: Address; +} + +function makeChain(opts: ChainStubOptions = {}): Chain & { + calls: { writes: Array; simulates: Array }; +} { + const calls = { + writes: [] as Array, + simulates: [] as Array, + }; + const chain = { + account: { address: VALIDATOR } as { address: Address }, + publicClient: { + readContract: async ({ + functionName, + args, + }: { + functionName: string; + args?: readonly unknown[]; + }) => { + if (functionName === "deliveryDurationDays") return opts.deliveryDurationDays ?? 7; + // start()'s pre-flight asserts the keeper signer == validator. Default + // matches `VALIDATOR` (the chain stub's account.address), so existing + // tests don't need to opt into anything. Set `validator: 0x...other` + // in opts to deliberately exercise the misalignment path. + if (functionName === "validatorAddress") return opts.validator ?? VALIDATOR; + if (functionName === "getPositionIds") { + const user = args?.[0] as Address | undefined; + if (user === undefined) throw new Error("getPositionIds called without user arg"); + return opts.positionIdsByUser?.get(user) ?? []; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + multicall: async ({ + contracts, + }: { + contracts: ReadonlyArray<{ functionName: string; args?: readonly unknown[] }>; + }) => { + return contracts.map((c) => { + if (c.functionName === "getPositionIds") { + const user = c.args?.[0] as Address; + return opts.positionIdsByUser?.get(user) ?? []; + } + if (c.functionName === "getPositionById") { + const id = c.args?.[0] as Hex; + return ( + opts.positionsById?.get(id) ?? { + // `_removePosition` deletes the slot so closed/missing + // positions read back as the zero-initialized struct. + seller: "0x0000000000000000000000000000000000000000" as Address, + buyer: "0x0000000000000000000000000000000000000000" as Address, + deliveryAt: 0n, + } + ); + } + throw new Error(`unexpected multicall function: ${c.functionName}`); + }); + }, + simulateContract: async (call: { args: readonly unknown[] }) => { + calls.simulates.push(call.args); + if (opts.simulate === undefined) throw new Error("test bug: simulate not configured"); + const out = opts.simulate(call.args); + if ("error" in out) throw out.error; + return out; + }, + writeContract: undefined, + waitForTransactionReceipt: async () => + opts.receipt ?? ({ blockNumber: 1n, logs: [] } as unknown as TransactionReceipt), + getBlockNumber: async () => opts.blockNumber ?? 0n, + // Sweep reads chain time from `getBlock().timestamp` (not Date.now) + // so it agrees with the contract's window guards. Default to wall + // clock so tests using `Date.now()`-derived `deliveryAt` still + // see the expected ordering. + getBlock: async () => ({ timestamp: BigInt(Math.floor(Date.now() / 1000)) }), + // Defaults so `withUnstickRetry` (used by `attemptBatch` to recover + // from `replacement transaction underpriced`) can run against the + // stub without exploding. `latest == pending` means "no stuck txs" + // → unstick is a no-op, then the original write retries. + getTransactionCount: async () => 0, + estimateFeesPerGas: async () => ({ + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 100_000_000n, + }), + getContractEvents: async ({ eventName }: { eventName: string }) => { + if (eventName === "PositionCreated") return opts.history?.PositionCreated ?? []; + if (eventName === "PositionClosed") return opts.history?.PositionClosed ?? []; + return []; + }, + watchContractEvent: ({ + eventName, + onLogs, + }: { + eventName: string; + onLogs: (logs: readonly Log[]) => void; + }) => { + if (opts.watchers !== undefined) { + if (eventName === "PositionCreated") { + opts.watchers.positionCreated = (logs) => onLogs(logs as unknown as readonly Log[]); + } else if (eventName === "PositionClosed") { + opts.watchers.positionClosed = (logs) => onLogs(logs as unknown as readonly Log[]); + } + } + return () => undefined; + }, + }, + walletClient: { + chain: null, + writeContract: async (req: readonly unknown[]) => { + calls.writes.push(req); + return opts.writeHash ?? ("0xdeadbeef" as `0x${string}`); + }, + // Used by unstick to send 0-value cancellation self-transfers. + // Returns a fake hash; tests that care about cancellations + // override this in the per-test chain object directly. + sendTransaction: async () => "0xcafe" as `0x${string}`, + }, + calls, + } as unknown as Chain & { + calls: { writes: Array; simulates: Array }; + }; + return chain; +} + +const FUTURES_ABI = parseAbi([ + "error PositionDeliveryNotStartedYet()", + "error PositionDeliveryExpired()", + "error PositionNotExists()", + "error OnlyValidatorOrPositionParticipant()", + "error UnknownProblem()", + "function closeDelivery(bytes32 positionId, bool blameSeller)", +]); + +/** Build the same shape of revert viem hands `simulateContract` callers. */ +function makeRevert(errorName: string): BaseError { + const inner = new ContractFunctionRevertedError({ + abi: FUTURES_ABI, + data: undefined, + functionName: "closeDelivery", + }); + (inner as unknown as { data: { errorName: string } }).data = { errorName }; + const outer = new BaseError("simulated revert"); + (outer as unknown as { cause: unknown }).cause = inner; + return outer; +} + +describe("DeliveryCoordinator: revert classification", () => { + it("recognises the contract's settlement-window guards as recoverable", () => { + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryNotStartedYet")), + "PositionDeliveryNotStartedYet", + ); + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryExpired")), + "PositionDeliveryExpired", + ); + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("PositionNotExists")), + "PositionNotExists", + ); + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("OnlyValidatorOrPositionParticipant")), + "OnlyValidatorOrPositionParticipant", + ); + }); + + it("treats oracle freshness reverts as recoverable so the sweep retries", () => { + assert.equal(__testing.decodeRecoverableRevert(makeRevert("OracleStale")), "OracleStale"); + assert.equal(__testing.decodeRecoverableRevert(makeRevert("InvalidOracle")), "InvalidOracle"); + }); + + it("does not classify unknown reverts as recoverable", () => { + assert.equal(__testing.decodeRecoverableRevert(makeRevert("UnknownProblem")), undefined); + assert.equal(__testing.decodeRecoverableRevert(new Error("boom")), undefined); + }); +}); + +describe("DeliveryCoordinator: live event handling", () => { + it("indexes positions on PositionCreated and removes them on PositionClosed", async () => { + const watchers: ChainStubOptions["watchers"] = {}; + const chain = makeChain({ watchers }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); + assert.equal(coordinator.size(), 1); + assert.ok(coordinator.has(POSITION_A)); + + watchers.positionClosed?.([positionClosedLog(POSITION_A)]); + assert.equal(coordinator.size(), 0); + coordinator.stop(); + }); + + it("dedupes duplicate PositionCreated for the same id (live + backfill overlap)", async () => { + const watchers: ChainStubOptions["watchers"] = {}; + const chain = makeChain({ watchers }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); + watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); + assert.equal(coordinator.size(), 1); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: settle()", () => { + it("simulates and broadcasts closeDelivery with the configured blame side", async () => { + let simulatedArgs: readonly unknown[] | undefined; + const chain = makeChain({ + simulate: (args) => { + simulatedArgs = args; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + }); + const coordinator = new DeliveryCoordinator( + chain, + makeConfig({ blameSeller: false }), + silentLogger, + ); + await coordinator.start(); + // Inject directly via the live watcher stub so we don't have to wait + // on a real timer. + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + + assert.deepEqual(simulatedArgs, [POSITION_A, false]); + assert.equal(chain.calls.writes.length, 1); + assert.equal(coordinator.has(POSITION_A), false, "settled position is dropped"); + coordinator.stop(); + }); + + it("dryRun skips the broadcast but still drops the position from the index", async () => { + const chain = makeChain({ + simulate: () => ({ request: { ok: true } }), + }); + const config = makeConfig(); + (config.keeper as { dryRun: boolean }).dryRun = true; + const coordinator = new DeliveryCoordinator(chain, config, silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + + await coordinator.settle(POSITION_A); + assert.equal(chain.calls.writes.length, 0, "no tx in dryRun"); + assert.equal(coordinator.has(POSITION_A), false); + coordinator.stop(); + }); + + it("drops positions on PositionDeliveryExpired (no second attempt possible)", async () => { + const chain = makeChain({ + simulate: () => ({ error: makeRevert("PositionDeliveryExpired") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + + await coordinator.settle(POSITION_A); + assert.equal(chain.calls.writes.length, 0); + assert.equal(coordinator.has(POSITION_A), false, "expired position is dropped"); + coordinator.stop(); + }); + + it("drops positions on PositionNotExists (already settled by someone else)", async () => { + const chain = makeChain({ + simulate: () => ({ error: makeRevert("PositionNotExists") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + assert.equal(coordinator.has(POSITION_A), false); + coordinator.stop(); + }); + + it("keeps positions on PositionDeliveryNotStartedYet (sweep will retry)", async () => { + const chain = makeChain({ + simulate: () => ({ error: makeRevert("PositionDeliveryNotStartedYet") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + assert.equal(coordinator.has(POSITION_A), true, "still tracked — sweep retries later"); + coordinator.stop(); + }); + + it("keeps positions on OnlyValidatorOrPositionParticipant (signer not validator)", async () => { + const chain = makeChain({ + simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + assert.equal(coordinator.has(POSITION_A), true, "auth misconfig is recoverable"); + coordinator.stop(); + }); + + it("logs unknown simulate reverts at error and skips them from the batch", async () => { + // New batching contract: an unknown simulate revert does NOT take down + // the keeper. Instead it's logged at error level (visible to ops) and + // the offending position is filtered out so the rest of the batch + // still settles. Crashing on one bad apple was the old per-id + // behaviour and proved fragile in production — a single position with + // weird state would unhandled-reject the setTimeout-fired settle and + // exit the process. + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ simulate: () => ({ error: makeRevert("UnknownProblem") }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); // must NOT throw + const errors = calls.filter( + (c) => c.level === "error" && /non-recoverable error/.test(c.msg), + ); + assert.equal(errors.length, 1, "operator-visible error log"); + assert.equal(chain.calls.writes.length, 0, "no broadcast — nothing in batch"); + assert.equal(coordinator.has(POSITION_A), true, "left for next sweep to surface again"); + coordinator.stop(); + }); + + it("coalesces concurrent settle() calls for the same positionId", async () => { + // Block the first simulate via a deferred so the second call observes + // the in-flight set before the first finishes. + let simulateCount = 0; + let resolveFirst: () => void = () => undefined; + const blocker = new Promise((r) => { + resolveFirst = r; + }); + const chain = makeChain({ + simulate: () => { + simulateCount++; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + }); + // Slow the first simulate by patching the publicClient method via a typed + // wrapper that still satisfies viem's overloaded signature. + const realSimulate = chain.publicClient.simulateContract.bind(chain.publicClient); + const slowed = async (...args: unknown[]) => { + const out = await (realSimulate as (...a: unknown[]) => Promise)(...args); + if (simulateCount === 1) await blocker; + return out; + }; + (chain.publicClient as unknown as { simulateContract: typeof slowed }).simulateContract = + slowed; + + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + + const first = coordinator.settle(POSITION_A); + const second = coordinator.settle(POSITION_A); + resolveFirst(); + await Promise.all([first, second]); + + assert.equal(simulateCount, 1, "second concurrent settle is a no-op"); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: settleBatch()", () => { + it("bundles N closeDelivery calls into one Futures.multicall(bytes[]) tx", async () => { + // The whole point of batching: even with 3 candidates, we want exactly + // one writeContract call (one nonce) so a concurrent manual send or a + // stale pending tx can't cause `replacement transaction underpriced`. + let simulateCount = 0; + const writeArgs: unknown[] = []; + const chain = makeChain({ + simulate: () => { + simulateCount++; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + }); + const recordingWrite = async (req: unknown) => { + writeArgs.push(req); + return "0xfeed" as `0x${string}`; + }; + (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = + recordingWrite; + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + for (const id of [POSITION_A, POSITION_B, POSITION_C]) { + coordinator["tracked"].set(id, { + positionId: id, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + } + await coordinator.settleBatch([POSITION_A, POSITION_B, POSITION_C]); + assert.equal(simulateCount, 3, "simulated each candidate to filter reverters"); + assert.equal(writeArgs.length, 1, "exactly one broadcast — one nonce, no race"); + const req = writeArgs[0] as { functionName: string; args: readonly [readonly `0x${string}`[]] }; + assert.equal(req.functionName, "multicall"); + assert.equal(req.args[0].length, 3, "three encoded closeDelivery calls in the bundle"); + for (const id of [POSITION_A, POSITION_B, POSITION_C]) { + assert.equal(coordinator.has(id), false, `${id} dropped after multicall confirms`); + } + coordinator.stop(); + }); + + it("filters out per-id reverters before broadcast so one bad apple can't poison the batch", async () => { + // POSITION_B reverts in simulate (PositionNotExists); A and C are fine. + // The multicall must contain only A and C — including B would revert + // the entire bundle and leave A & C unsettled. Critical test. + let simulateCount = 0; + const writeArgs: unknown[] = []; + const chain = makeChain({ + simulate: (args: readonly unknown[]) => { + simulateCount++; + if (args[0] === POSITION_B) return { error: makeRevert("PositionNotExists") }; + return { request: { ok: true } }; + }, + }); + const recordingWrite = async (req: unknown) => { + writeArgs.push(req); + return "0xfeed" as `0x${string}`; + }; + (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = + recordingWrite; + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + for (const id of [POSITION_A, POSITION_B, POSITION_C]) { + coordinator["tracked"].set(id, { + positionId: id, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + } + await coordinator.settleBatch([POSITION_A, POSITION_B, POSITION_C]); + assert.equal(simulateCount, 3); + assert.equal(writeArgs.length, 1, "still one broadcast for the survivors"); + const req = writeArgs[0] as { args: readonly [readonly `0x${string}`[]] }; + assert.equal(req.args[0].length, 2, "B filtered out, A + C remain"); + assert.equal(coordinator.has(POSITION_A), false); + assert.equal(coordinator.has(POSITION_B), false, "PositionNotExists drops B from index"); + assert.equal(coordinator.has(POSITION_C), false); + coordinator.stop(); + }); + + it("treats `replacement transaction underpriced` as recoverable — does not throw", async () => { + // The original failure mode this whole batching change is designed + // to mitigate. Even when the underlying RPC bounces a replacement + // tx, the keeper must not crash — the next sweep tick will retry + // with a fresh nonce. + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); + const failingWrite = async () => { + throw new Error("replacement transaction underpriced"); + }; + (chain.walletClient as unknown as { writeContract: typeof failingWrite }).writeContract = + failingWrite; + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settleBatch([POSITION_A]); // must NOT throw + assert.equal(coordinator.has(POSITION_A), true, "left tracked for next sweep retry"); + const warns = calls.filter((c) => c.level === "warn" && /tx submission failed/.test(c.msg)); + assert.equal(warns.length, 1, "warned about transient submission failure"); + coordinator.stop(); + }); + + it("respects DELIVERY_MAX_BATCH_SIZE by splitting large sweeps across multiple multicalls", async () => { + // 5 candidates, batch size 2 → 3 multicalls (2 + 2 + 1). + const writeArgs: unknown[] = []; + const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); + const recordingWrite = async (req: unknown) => { + writeArgs.push(req); + return "0xfeed" as `0x${string}`; + }; + (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = + recordingWrite; + const coordinator = new DeliveryCoordinator( + chain, + makeConfig({ maxBatchSize: 2 }), + silentLogger, + ); + await coordinator.start(); + // Real 32-byte hex — the production code calls `encodeFunctionData` + // which strictly validates `bytes32` width, so test fixtures must + // match that width or encode throws before any tx is built. + const ids: `0x${string}`[] = [ + `0xa${"1".repeat(63)}`, + `0xa${"2".repeat(63)}`, + `0xa${"3".repeat(63)}`, + `0xa${"4".repeat(63)}`, + `0xa${"5".repeat(63)}`, + ] as `0x${string}`[]; + // Just past delivery so sweep picks them up but the settlement window + // (default 7d) hasn't expired — `deliveryAt: 0n` would land outside + // the window vs. the wall-clock-based stub `getBlock()` timestamp and + // get silently dropped before any broadcast. + const recentlyPast = BigInt(Math.floor(Date.now() / 1000)) - 60n; + for (const id of ids) { + coordinator["tracked"].set(id, { + positionId: id, + deliveryAt: recentlyPast, + seller: SELLER, + buyer: BUYER, + }); + } + await coordinator.sweep(); + assert.equal(writeArgs.length, 3, "5 ids @ batch=2 → ceil(5/2) multicalls"); + const sizes = writeArgs.map((r) => (r as { args: [`0x${string}`[]] }).args[0].length); + assert.deepEqual(sizes.sort(), [1, 2, 2]); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: backfill", () => { + it("seeds the index from historical PositionCreated and respects subsequent PositionClosed", async () => { + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => ({ request: { ok: true } }), + writeHash: "0xfeed", + history: { + PositionCreated: [ + positionCreatedLog(POSITION_A, future), + positionCreatedLog(POSITION_B, future + 86_400n), + positionCreatedLog(POSITION_C, future + 2n * 86_400n), + ], + // C was already closed historically — backfill should not leave it scheduled. + PositionClosed: [positionClosedLog(POSITION_C)], + }, + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + await coordinator.backfill(0n, 10_000n); + + assert.equal(coordinator.size(), 2); + assert.ok(coordinator.has(POSITION_A)); + assert.ok(coordinator.has(POSITION_B)); + assert.equal(coordinator.has(POSITION_C), false); + coordinator.stop(); + }); + + it("settles past-due positions found during backfill on the immediate sweep", async () => { + const past = BigInt(Math.floor(Date.now() / 1000)) - 60n; // 60s ago + let simulateCount = 0; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => { + simulateCount++; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + history: { + PositionCreated: [positionCreatedLog(POSITION_A, past)], + }, + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + await coordinator.backfill(0n, 10_000n); + + assert.equal(simulateCount, 1, "past-due position settled on backfill sweep"); + assert.equal(chain.calls.writes.length, 1); + assert.equal(coordinator.has(POSITION_A), false); + coordinator.stop(); + }); + + it("drops positions whose entire settlement window has already expired", async () => { + // 7 days * 86400 s + extra → window expired + const longAgo = BigInt(Math.floor(Date.now() / 1000)) - 8n * 86_400n; + let simulateCount = 0; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => { + simulateCount++; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + deliveryDurationDays: 7, + history: { + PositionCreated: [positionCreatedLog(POSITION_A, longAgo)], + }, + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + await coordinator.backfill(0n, 10_000n); + + assert.equal(simulateCount, 0, "no settlement attempted for expired window"); + assert.equal(coordinator.has(POSITION_A), false, "expired position pruned"); + coordinator.stop(); + }); + + it("rejects non-positive chunkSize", async () => { + const chain = makeChain({ blockNumber: 1000n }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + await assert.rejects(() => coordinator.backfill(0n, 0n)); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: validator pre-flight", () => { + it("start() throws when keeper signer is not Futures.validatorAddress()", async () => { + const OTHER = "0x0000000000000000000000000000000000000001" as Address; + const chain = makeChain({ validator: OTHER }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await assert.rejects( + () => coordinator.start(), + /signer is not the futures validator/i, + "boot must fail loudly so the orchestrator restart loop pages on-call", + ); + }); + + it("start() succeeds when keeper signer matches the validator", async () => { + const chain = makeChain({ validator: VALIDATOR }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: recoverable-revert log severity", () => { + // The whole point of differentiated logging: at the default `info` log + // level, an operator should *immediately* see operational misconfigs + // (wrong validator key, missed delivery window) without having to flip + // LOG_LEVEL=debug. Transient reverts the sweep will retry stay at debug + // so they don't drown out everything else. + + it("errors once when the keeper signer is not the validator (page-worthy)", async () => { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ + simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + const errors = calls.filter((c) => c.level === "error"); + assert.equal(errors.length, 1, "first occurrence is an error the operator must see"); + assert.equal(errors[0]?.obj.revert, "OnlyValidatorOrPositionParticipant"); + assert.match(errors[0]?.msg ?? "", /signer is not Futures\.validatorAddress/); + assert.equal(coordinator.has(POSITION_A), true, "auth misconfig is recoverable; position kept"); + coordinator.stop(); + }); + + it("dedupes repeated auth-failure errors to debug to avoid flooding", async () => { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ + simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + await coordinator.settle(POSITION_A); + await coordinator.settle(POSITION_A); + const errors = calls.filter((c) => c.level === "error"); + assert.equal(errors.length, 1, "subsequent attempts on same position do not re-error"); + }); + + it("errors once when settlement window has expired and drops the position", async () => { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ simulate: () => ({ error: makeRevert("PositionDeliveryExpired") }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + const errors = calls.filter((c) => c.level === "error"); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.obj.revert, "PositionDeliveryExpired"); + assert.match(errors[0]?.msg ?? "", /settlement window already expired/); + assert.equal(coordinator.has(POSITION_A), false, "expired window → drop"); + coordinator.stop(); + }); + + it("logs at info (not error) when someone else already settled the position", async () => { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ simulate: () => ({ error: makeRevert("PositionNotExists") }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + const infos = calls.filter((c) => c.level === "info" && c.obj.revert === "PositionNotExists"); + const loud = calls.filter((c) => c.level === "warn" || c.level === "error"); + assert.equal(infos.length, 1, "benign termination → info"); + assert.equal(loud.length, 0, "not an operator-actionable failure → no warn/error"); + coordinator.stop(); + }); + + it("keeps transient reverts (NotStartedYet, OracleStale, InvalidOracle) at debug", async () => { + for (const revert of ["PositionDeliveryNotStartedYet", "OracleStale", "InvalidOracle"]) { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain({ simulate: () => ({ error: makeRevert(revert) }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: 0n, + seller: SELLER, + buyer: BUYER, + }); + await coordinator.settle(POSITION_A); + const debugs = calls.filter((c) => c.level === "debug" && c.obj.revert === revert); + const loud = calls.filter((c) => c.level === "warn" || c.level === "error"); + assert.ok(debugs.length >= 1, `${revert} should debug-log`); + assert.equal(loud.length, 0, `${revert} is transient — must not warn/error`); + coordinator.stop(); + } + }); +}); + +describe("DeliveryCoordinator: view-based discovery", () => { + it("bootstrapFromUsers indexes still-alive positions and skips closed ones", async () => { + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + const userA = "0x000000000000000000000000000000000000A11C" as Address; + const userB = "0x000000000000000000000000000000000000b0b1" as Address; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => ({ request: { ok: true } }), + writeHash: "0xfeed", + positionIdsByUser: new Map([ + [userA, [POSITION_A, POSITION_C]], + [userB, [POSITION_B]], + ]), + positionsById: new Map([ + [POSITION_A, { seller: SELLER, buyer: userA, deliveryAt: future }], + [POSITION_B, { seller: SELLER, buyer: userB, deliveryAt: future + 86_400n }], + // POSITION_C: not in the map → zero-struct → seller==0 → already closed → skipped. + ]), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + await coordinator.bootstrapFromUsers([userA, userB]); + + assert.equal(coordinator.size(), 2, "two live positions indexed, closed one skipped"); + assert.ok(coordinator.has(POSITION_A)); + assert.ok(coordinator.has(POSITION_B)); + assert.equal(coordinator.has(POSITION_C), false); + coordinator.stop(); + }); + + it("bootstrapFromUsers settles past-due positions on the trailing sweep", async () => { + const past = BigInt(Math.floor(Date.now() / 1000)) - 60n; + const user = "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4" as Address; + let simulateCount = 0; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => { + simulateCount++; + return { request: { ok: true } }; + }, + writeHash: "0xfeed", + positionIdsByUser: new Map([[user, [POSITION_A]]]), + positionsById: new Map([[POSITION_A, { seller: SELLER, buyer: user, deliveryAt: past }]]), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + await coordinator.bootstrapFromUsers([user]); + + assert.equal(simulateCount, 1, "past-due position settled on bootstrap sweep"); + assert.equal(chain.calls.writes.length, 1); + assert.equal(coordinator.has(POSITION_A), false); + coordinator.stop(); + }); + + it("bootstrapFromUsers is a no-op for an empty user list", async () => { + const chain = makeChain({ blockNumber: 1000n }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + await coordinator.bootstrapFromUsers([]); + + assert.equal(coordinator.size(), 0); + coordinator.stop(); + }); + + it("bootstrapFromUsers does not re-index already-tracked positions", async () => { + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + const user = "0x000000000000000000000000000000000000A11C" as Address; + const chain = makeChain({ + blockNumber: 1000n, + simulate: () => ({ request: { ok: true } }), + writeHash: "0xfeed", + positionIdsByUser: new Map([[user, [POSITION_A]]]), + positionsById: new Map([[POSITION_A, { seller: SELLER, buyer: user, deliveryAt: future }]]), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator["tracked"].set(POSITION_A, { + positionId: POSITION_A, + deliveryAt: future, + seller: SELLER, + buyer: user, + }); + + await coordinator.bootstrapFromUsers([user]); + + assert.equal(coordinator.size(), 1, "no duplicate entry"); + coordinator.stop(); + }); + + it("indexUserPositions discovers a single user's positions", async () => { + const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + const user = "0x000000000000000000000000000000000000A11C" as Address; + const chain = makeChain({ + blockNumber: 1000n, + positionIdsByUser: new Map([[user, [POSITION_A, POSITION_B]]]), + positionsById: new Map([ + [POSITION_A, { seller: SELLER, buyer: user, deliveryAt: future }], + [POSITION_B, { seller: SELLER, buyer: user, deliveryAt: future + 86_400n }], + ]), + }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + + await coordinator.indexUserPositions(user); + + assert.equal(coordinator.size(), 2); + assert.ok(coordinator.has(POSITION_A)); + assert.ok(coordinator.has(POSITION_B)); + coordinator.stop(); + }); + + it("indexUserPositions swallows getPositionIds RPC errors instead of throwing", async () => { + const user = "0x000000000000000000000000000000000000A11C" as Address; + const chain = makeChain({ blockNumber: 1000n }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + // Force getPositionIds to blow up *after* startup (which uses readContract + // for `deliveryDurationDays`). The listener path must never throw — + // bubbling out would crash the tracker's onAdded fan-out. + chain.publicClient.readContract = (async () => { + throw new Error("rpc down"); + }) as unknown as typeof chain.publicClient.readContract; + + await coordinator.indexUserPositions(user); + assert.equal(coordinator.size(), 0, "discovery error degrades silently"); + coordinator.stop(); + }); +}); + +describe("DeliveryCoordinator: lifecycle", () => { + it("start() is idempotent", async () => { + const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + await coordinator.start(); + coordinator.stop(); + }); + + it("stop() is idempotent and clears all timers", async () => { + const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coordinator.start(); + coordinator.stop(); + coordinator.stop(); + }); +}); diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 1ac144c..067f540 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -4,6 +4,7 @@ import { createWalletClient, http, type Account, + type Address, type PublicClient, type WalletClient, } from "viem"; @@ -21,6 +22,7 @@ import { PerpsVenue } from "../../src/venues/perps.ts"; import { FuturesVenue } from "../../src/venues/futures.ts"; import { PriceFeed } from "../../src/oracle/priceFeed.ts"; import { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; +import { DeliveryCoordinator } from "../../src/delivery/coordinator.ts"; import type { Venue } from "../../src/venues/types.ts"; import type { DeployedStack } from "./deployStack.ts"; import { HARDHAT_PRIVATE_KEYS } from "./deployStack.ts"; @@ -50,6 +52,14 @@ export interface KeeperHarness { notifier: Notifier; priceFeed: PriceFeed; predictor: PredictiveCoordinator; + /** + * Only present when `BuildKeeperOverrides.delivery` is set. Tests that + * exercise the delivery module must pass `delivery: true` and ensure the + * keeper signer equals the Futures contract's `validatorAddress` — + * otherwise every `closeDelivery` simulate fails authorization and the + * sweep silently no-ops. + */ + delivery?: DeliveryCoordinator; start(): Promise; stop(): Promise; } @@ -62,6 +72,27 @@ export interface BuildKeeperOverrides { logLevel?: pino.Level; /** Inject your own keeper signer key. Defaults to Hardhat account #3. */ liquidatorPrivateKey?: `0x${string}`; + /** + * Wire up the optional `DeliveryCoordinator`. Defaults to false. When + * true, tests should also pass `liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4]` + * (the validator) so `closeDelivery` simulations pass the contract's + * `_msgSender() == validatorAddress` guard. + */ + delivery?: boolean; + /** + * Manual seed list for the delivery coordinator. Mirrors + * `DELIVERY_BOOTSTRAP_USERS` in production. Tests use it to verify that + * a known stuck user can be settled even when the tracker never + * discovered them (e.g. log backfill broken on a rate-limited RPC). + */ + deliveryBootstrapUsers?: readonly Address[]; + /** + * Maximum closeDelivery calls bundled into one Futures.multicall 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). + */ + deliveryMaxBatchSize?: number; } const LIQUIDATOR_PK = HARDHAT_PRIVATE_KEYS[3]; @@ -113,6 +144,13 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride // wires in production. tracker.onAdded(() => executor.kick()); + // Optional delivery coordinator — opt-in per test. Built but not started; + // start() below boots it after the live tracker is up so it sees the same + // event ordering production does. + const delivery = overrides.delivery === true + ? new DeliveryCoordinator(chain, config, logger) + : undefined; + let started = false; return { config, @@ -125,12 +163,14 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride notifier, priceFeed, predictor, + delivery, async start() { if (started) return; started = true; await priceFeed.start(); await predictor.start(); await tracker.start(); + if (delivery !== undefined) await delivery.start(); await executor.start(); // Scheduler is NOT started: tests drive it manually via // `scheduler.runSweep()` to avoid timer races against `evm_revert`. @@ -141,6 +181,7 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride scheduler.stop(); predictor.stop(); priceFeed.stop(); + delivery?.stop(); await executor.stop(); tracker.stop(); }, @@ -186,6 +227,29 @@ function buildConfig(stack: DeployedStack, overrides: BuildKeeperOverrides): Con sweepIntervalMs: overrides.sweepIntervalMs ?? 60_000, healthPort: 0, logLevel: overrides.logLevel ?? "warn", + // Effectively disabled in integration tests — the monitor is wired + // in production (see `index.ts`) but has no role in fixture-driven + // assertions, and a 5-min interval would never fire anyway. + balanceCheckIntervalMs: 60 * 60 * 1000, + 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, + blameSeller: true, + // Tighter than production so tests don't have to wait a minute for + // the safety-net sweep when they want to verify backfill behaviour. + sweepIntervalMs: 1_000, + settleDelayMs: 0, + bootstrapUsers: overrides.deliveryBootstrapUsers ?? [], + maxBatchSize: overrides.deliveryMaxBatchSize ?? 50, }, }; } diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index c8ac03f..b7a38a9 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -183,17 +183,26 @@ export async function deployStack(rpcUrl: string): Promise { // ── Vault (UUPS proxy) ──────────────────────────────────────────────── const vaultArt = artifacts.vault(); const vaultImpl = await deploy(publicClient, owner.client, vaultArt, []); - const vault = await deployProxy(publicClient, owner.client, vaultImpl, vaultArt.abi, "initialize", [ - usdc, - ]); + const vault = await deployProxy( + publicClient, + owner.client, + vaultImpl, + vaultArt.abi, + "initialize", + [usdc], + ); // ── Perps (UUPS proxy) ──────────────────────────────────────────────── const perpsArt = artifacts.perps(); const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [MIN_PRICE_INCREMENT]); - const perps = await deployProxy(publicClient, owner.client, perpsImpl, perpsArt.abi, "initialize", [ - hashpriceOracle, - vault, - ]); + const perps = await deployProxy( + publicClient, + owner.client, + perpsImpl, + perpsArt.abi, + "initialize", + [hashpriceOracle, vault], + ); // ── Futures (UUPS proxy, takes vault in constructor) ────────────────── const futuresArt = artifacts.futures(); @@ -234,7 +243,10 @@ export async function deployStack(rpcUrl: string): Promise { // Vault -> point at the single margin engine + authorize each venue. await write(publicClient, owner.client, vault, vaultArt.abi, "setMarginEngine", [pme]); - await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [perps, true]); + await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [ + perps, + true, + ]); await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [ futures, true, @@ -338,7 +350,7 @@ async function deploy( chain: hardhat, }); const receipt = await pc.waitForTransactionReceipt({ hash }); - if (receipt.contractAddress === null) { + if (!receipt.contractAddress) { throw new Error("deployContract receipt missing contractAddress"); } return receipt.contractAddress; diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 8b3a547..a221aab 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -181,6 +181,30 @@ export const readPerpsOrderLiquidationBlock = (s: DeployedStack, u: Address) => export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) => earliestEventBlock(s, "futures", "OrderLiquidated", { participant: u }); +/** + * Earliest block at which `Futures.PositionDeliveryClosed(positionId)` was + * emitted. Used by the delivery-coordinator e2e tests to confirm the keeper + * actually sent `closeDelivery` for a specific position id. + */ +export async function readPositionDeliveryClosedBlock( + stack: DeployedStack, + positionId: Hex, +): Promise { + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses.futures, + abi: stack.abis.futures, + eventName: "PositionDeliveryClosed", + args: { positionId }, + fromBlock: 0n, + }); + let earliest: bigint | null = null; + for (const log of logs) { + if (log.blockNumber === null) continue; + if (earliest === null || log.blockNumber < earliest) earliest = log.blockNumber; + } + return earliest; +} + async function earliestEventBlock( stack: DeployedStack, venue: "perps" | "futures", diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 75a9142..5ffee48 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -31,10 +31,12 @@ import { runOneSweep, readPerpsOrderIds, readFuturesOrderIds, + readFuturesPositionIds, readPerpsPositionLiquidationBlock, readFuturesPositionLiquidationBlock, readPerpsOrderLiquidationBlock, readFuturesOrderLiquidationBlock, + readPositionDeliveryClosedBlock, readPerpsPosition, expectPerpsClosed, expectFuturesClosed, @@ -43,6 +45,7 @@ import { isCriticalAlert, waitFor, } from "./helpers.ts"; +import { HARDHAT_PRIVATE_KEYS } from "./deployStack.ts"; /** * Integration test suite for `@collateral-margin/keeper`. @@ -606,6 +609,282 @@ describe("Notifier (live HTTP)", () => { ); }); +// ───────────────────────────────────────────────────────────────────────── +// Delivery coordinator (live RPC, opt-in keeper module) +// ───────────────────────────────────────────────────────────────────────── + +describe("DeliveryCoordinator (live RPC)", () => { + it( + "settles a futures position at its delivery date with the current market price", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds a single long futures contract created + // at fixture time. The keeper boots with delivery enabled and the + // *validator* signing key — so `closeDelivery` simulations clear + // the contract's `_msgSender() == validatorAddress` guard. + // + // We then fast-forward the chain past `deliveryAt` and trigger one + // sweep. The contract's `_closeAndCashSettleDelivery` cash-settles + // the entire window at the current market price (positionElapsedTime + // = 0 → no contract-price portion), and emits `PositionDeliveryClosed` + // followed by `PositionClosed`. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + keeper = buildKeeper(ctx, { + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator + delivery: true, + }); + await keeper.start(); + assert.ok(keeper.delivery, "delivery coordinator should be wired when override is true"); + + const alice = ctx.accounts.alice.account.address; + const positionsBefore = await readFuturesPositionIds(ctx, alice); + // The fixture creates one position per matched contract — Alice's + // 12-contract long becomes 12 separate position entries sharing one + // `deliveryAt`. Settling them all is the realistic case (one signer + // serializing many positions due at the same timestamp). + assert.equal(positionsBefore.length, ctx.aliceFuturesQty); + + // Seed the delivery index from history — the positions were created + // before the keeper booted, so the live watcher hasn't seen them. + await keeper.delivery.backfill(0n, 10_000n); + for (const id of positionsBefore) { + assert.ok(keeper.delivery.has(id), `backfill should index position ${id}`); + } + + // Fast-forward past `deliveryAt`. `closeDelivery` requires + // `block.timestamp >= position.deliveryAt`, and `block.timestamp` is + // only advanced once a block is mined at the new clock. + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + await testClient.mine({ blocks: 1 }); + + // The hashprice oracle has been silent for 7 days — refresh it so + // `_getHashpriceUsd` doesn't revert `OracleStale` inside + // `closeDelivery`. We re-post the entry price; the cash-settlement + // formula uses this as the "current market price" applied to the + // full delivery window (positionElapsedTime = 0). + await ctx.bumpHashprice(ctx.config.initialHashprice); + + await keeper.delivery.sweep(); + + // End state: every position is gone from chain storage, each emitted + // a `PositionDeliveryClosed` event from the keeper's signer, and the + // index dropped all of them. + await expectFuturesClosed(ctx, alice); + const settledBlocks: bigint[] = []; + for (const id of positionsBefore) { + const settledBlock = await readPositionDeliveryClosedBlock(ctx, id); + assert.ok( + settledBlock !== null, + `expected a PositionDeliveryClosed event for position ${id}`, + ); + settledBlocks.push(settledBlock); + assert.equal(keeper.delivery.has(id), false, `settled position ${id} is dropped`); + } + // Batching invariant: all 12 settlements ride a single + // `Futures.multicall(bytes[])` transaction, so every + // `PositionDeliveryClosed` event lands in the same block. Without + // batching they would have been N separate txs across N blocks + // (plus a `replacement transaction underpriced` race in production + // when two of them collided on the same nonce). This assertion + // locks in the multicall path — if someone reverts the coordinator + // to per-id sends, the blocks fan out and this fails. + const uniqueBlocks = new Set(settledBlocks.map((b) => b.toString())); + assert.equal( + uniqueBlocks.size, + 1, + `expected all settlements in one multicall block, got ${uniqueBlocks.size} distinct blocks: ${[...uniqueBlocks].join(", ")}`, + ); + }, + ); + + it( + "sweeps missing past deliveries during backfill — settles immediately on boot", + { timeout: 60_000 }, + async () => { + // Precondition: alice's position was created at fixture time and + // its `deliveryAt` is *already in the past* by the time the keeper + // boots. The contract is the spec for "missing delivery": until + // someone calls `closeDelivery` the position lingers, and the + // settlement window stays open for `deliveryDurationDays`. + // + // Contract under test: `backfill()` discovers the position from + // history AND its trailing `sweep()` settles it on the same boot — + // no live event, no scheduler tick required. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + + // Move time past deliveryAt *before* the keeper boots, so the live + // subscription would miss the (long-past) PositionCreated event. + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); + await testClient.mine({ blocks: 1 }); + // Refresh the oracle so `_getHashpriceUsd` doesn't revert `OracleStale` + // when settlement reads the mark. + await ctx.bumpHashprice(ctx.config.initialHashprice); + + keeper = buildKeeper(ctx, { + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], + delivery: true, + }); + await keeper.start(); + assert.ok(keeper.delivery); + + const alice = ctx.accounts.alice.account.address; + const positionsBefore = await readFuturesPositionIds(ctx, alice); + assert.ok(positionsBefore.length > 0, "precondition: alice has positions to settle"); + + // backfill() runs an immediate sweep at the end — past-due positions + // settle without waiting on the periodic timer. + await keeper.delivery.backfill(0n, 10_000n); + + await expectFuturesClosed(ctx, alice); + for (const id of positionsBefore) { + assert.ok( + (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + `missed delivery for ${id} should be settled by backfill sweep`, + ); + } + }, + ); + + it( + "bootstrapFromUsers indexes & settles via contract views (no log scan)", + { timeout: 60_000 }, + async () => { + // Production reality: on Alchemy free tier `eth_getLogs` is capped + // at 10 blocks, so log-based backfill is unusable for any non-trivial + // window. The view-based discovery path (`bootstrapFromUsers`) reads + // `getPositionIds` + `getPositionById` directly from contract storage, + // sidestepping the log limit entirely. This test exercises that exact + // recovery shape: we never call `backfill()` — only `bootstrapFromUsers` + // — and verify every still-alive position is found and settled. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + keeper = buildKeeper(ctx, { + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], + delivery: true, + }); + await keeper.start(); + assert.ok(keeper.delivery); + + const alice = ctx.accounts.alice.account.address; + const positionsBefore = await readFuturesPositionIds(ctx, alice); + assert.ok(positionsBefore.length > 0); + + await keeper.delivery.bootstrapFromUsers([alice]); + for (const id of positionsBefore) { + assert.ok(keeper.delivery.has(id), `bootstrap should index position ${id}`); + } + + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + await testClient.mine({ blocks: 1 }); + await ctx.bumpHashprice(ctx.config.initialHashprice); + + await keeper.delivery.sweep(); + + await expectFuturesClosed(ctx, alice); + for (const id of positionsBefore) { + assert.ok( + (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + `position ${id} should be settled via view-based bootstrap`, + ); + assert.equal(keeper.delivery.has(id), false); + } + }, + ); + + it( + "DELIVERY_BOOTSTRAP_USERS recovers a stuck user the tracker never discovered", + { timeout: 60_000 }, + async () => { + // Operational scenario from production: the tracker's log backfill + // failed (Alchemy free tier rate-limits eth_getLogs), so a known user + // with a past-due futures position is invisible to every other + // discovery path. Operator sets DELIVERY_BOOTSTRAP_USERS= as + // an emergency seed; the coordinator reads the user's positions via + // the view path and settles them on the first sweep. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + + // Move past deliveryAt before boot — same shape as the production + // outage where the keeper has been down/blind during the delivery + // window. + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); + await testClient.mine({ blocks: 1 }); + await ctx.bumpHashprice(ctx.config.initialHashprice); + + const alice = ctx.accounts.alice.account.address; + keeper = buildKeeper(ctx, { + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], + delivery: true, + deliveryBootstrapUsers: [alice], + }); + await keeper.start(); + assert.ok(keeper.delivery); + + const positionsBefore = await readFuturesPositionIds(ctx, alice); + assert.ok(positionsBefore.length > 0, "precondition: alice has past-due positions"); + + // Mimic the boot wiring: tracker.list() is empty (we never started + // backfill / live discovery), but the manual seed list configured via + // DELIVERY_BOOTSTRAP_USERS still feeds the coordinator. + await keeper.delivery.bootstrapFromUsers(keeper.config.delivery.bootstrapUsers); + assert.deepEqual( + [...keeper.config.delivery.bootstrapUsers], + [alice], + "bootstrap list should be the seeded address", + ); + + await expectFuturesClosed(ctx, alice); + for (const id of positionsBefore) { + assert.ok( + (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + `manually-seeded position ${id} should be settled`, + ); + } + }, + ); + + it( + "refuses to start when the keeper signer is not the futures validator", + { timeout: 60_000 }, + async () => { + // Operator-safety contract: if `DELIVERY_KEEPER_ENABLED=true` is set + // but `LIQUIDATOR_PRIVATE_KEY` does not derive to + // `Futures.validatorAddress()`, the coordinator must throw at start. + // Bubbles up to `main().catch` → `process.exit(1)`; the orchestrator + // (k8s, systemd) sees the crash, healthcheck flips to 503, and + // standard infra alerting (CrashLoopBackOff etc.) pages on-call. + // The alternative — silent `simulateContract` reverts at debug level — + // is invisible at the default `info` log level and was the actual + // production failure mode that motivated this check. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + const k = buildKeeper(ctx, { + // Default liquidator key (account #3), NOT the validator (#4). + delivery: true, + }); + keeper = k; + await assert.rejects( + () => k.start(), + /signer is not the futures validator/i, + "boot must fail loudly so the operator cannot miss the misconfig", + ); + + // Sanity: nothing was indexed and no positions were settled. + const alice = ctx.accounts.alice.account.address; + const positionsBefore = await readFuturesPositionIds(ctx, alice); + assert.ok(positionsBefore.length > 0, "fixture should have created positions"); + for (const id of positionsBefore) { + assert.equal( + await readPositionDeliveryClosedBlock(ctx, id), + null, + `position ${id} must not be settled by a misconfigured keeper`, + ); + } + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────── // Local utilities // ───────────────────────────────────────────────────────────────────────── diff --git a/keeper/tests/oracle/ethUsdFeed.test.ts b/keeper/tests/oracle/ethUsdFeed.test.ts new file mode 100644 index 0000000..2dc341e --- /dev/null +++ b/keeper/tests/oracle/ethUsdFeed.test.ts @@ -0,0 +1,181 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address } from "viem"; +import { EthUsdFeed } from "../../src/oracle/ethUsdFeed.ts"; +import type { Chain } from "../../src/chain.ts"; + +const FEED_ADDR: Address = "0x00000000000000000000000000000000000000F0"; +const SILENT = pino({ level: "silent" }); + +interface FakeReads { + /** Per-call answer queue; falls back to last entry when exhausted. */ + answers: bigint[]; + decimals?: number; + /** Optional callbacks to simulate per-call failures. */ + failNextDecimalsRead?: boolean; + failNextAnswerReadCount?: number; +} + +function makeChain(reads: FakeReads): { chain: Chain; calls: { decimals: number; latest: number } } { + const calls = { decimals: 0, latest: 0 }; + const decimals = reads.decimals ?? 8; + const chain = { + publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") { + calls.decimals++; + if (reads.failNextDecimalsRead) { + reads.failNextDecimalsRead = false; + throw new Error("decimals rpc failed"); + } + return decimals; + } + if (functionName === "latestRoundData") { + calls.latest++; + if ((reads.failNextAnswerReadCount ?? 0) > 0) { + reads.failNextAnswerReadCount = (reads.failNextAnswerReadCount ?? 0) - 1; + throw new Error("latestRoundData rpc failed"); + } + const i = Math.min(calls.latest - 1, reads.answers.length - 1); + return [0n, reads.answers[i] as bigint, 0n, 0n, 0n] as const; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + }, + } as unknown as Chain; + return { chain, calls }; +} + +describe("EthUsdFeed", () => { + it("current() is undefined until the first refresh succeeds", async () => { + const { chain } = makeChain({ + answers: [], + failNextAnswerReadCount: 1, + failNextDecimalsRead: false, + }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + assert.equal(feed.current(), undefined); + feed.stop(); + }); + + it("populates current() and updatedAt() after a successful refresh", async () => { + const before = Date.now(); + const { chain, calls } = makeChain({ answers: [3000_00000000n], decimals: 8 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + assert.equal(feed.current(), 3000_00000000n); + assert.ok((feed.updatedAt() ?? 0) >= before); + assert.equal(calls.decimals, 1); + assert.equal(calls.latest, 1); + feed.stop(); + }); + + it("reads decimals only once and reuses it across refreshes", async () => { + const { chain, calls } = makeChain({ answers: [2500_00000000n, 2600_00000000n] }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + await feed.refresh(); + assert.equal(calls.decimals, 1, "decimals are immutable on Chainlink — read once"); + assert.equal(calls.latest, 2); + feed.stop(); + }); + + it("keeps the previous price when latestRoundData returns a non-positive answer", async () => { + const { chain } = makeChain({ answers: [3000_00000000n, 0n] }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + await feed.refresh(); + assert.equal(feed.current(), 3000_00000000n, "non-positive answer should NOT clobber the price"); + feed.stop(); + }); + + it("keeps the previous price when the RPC throws — feed is never fatal for logging", async () => { + const { chain } = makeChain({ answers: [3000_00000000n], failNextAnswerReadCount: 0 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + // Next refresh fails, but `current()` should still report the prior price. + chain.publicClient.readContract = async ({ functionName }) => { + if (functionName === "decimals") return 8; + throw new Error("rpc down"); + }; + await feed.refresh(); + assert.equal(feed.current(), 3000_00000000n); + feed.stop(); + }); + + describe("weiToUsd", () => { + it("returns undefined before the first successful refresh", () => { + const { chain } = makeChain({ answers: [] }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + assert.equal(feed.weiToUsd(10n ** 18n), undefined); + feed.stop(); + }); + + it("converts wei to USD at the cached oracle price (8 decimals)", async () => { + // ETH/USD = $3000 with 8 decimals → raw answer 300000000000. + const { chain } = makeChain({ answers: [300_000_000_000n], decimals: 8 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + // 1 ETH = 1e18 wei → expect 3000 USD. + assert.equal(feed.weiToUsd(10n ** 18n), 3000); + // 0.001 ETH = 1e15 wei → expect 3 USD. + assert.equal(feed.weiToUsd(10n ** 15n), 3); + feed.stop(); + }); + + it("preserves sub-cent resolution for L2-cheap txs", async () => { + // ETH/USD = $3000, 8 decimals. Realistic Base sweep gas budget: + // 50k gas at 0.01 gwei = 5e11 wei. + // USD = 5e11 * 3000 / 1e18 = 1.5e-3 USD = $0.0015 (one-and-a-half mils). + // weiToUsd returns the unrounded float; formatGasCost rounds to 6dp. + const { chain } = makeChain({ answers: [300_000_000_000n], decimals: 8 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + const usd = feed.weiToUsd(500_000_000_000n); + assert.ok(usd !== undefined); + // Assert via integer scaling so we're not testing fp soup. + assert.equal(Math.round((usd as number) * 1_000_000), 1500); + feed.stop(); + }); + + it("does not round tiny tx costs to zero (sub-micro-USD is still representable)", async () => { + // 1 gwei worth of wei at $3000/ETH = 3e-9 USD. Tiny but non-zero — + // weiToUsd must preserve it so the rounding decision is up to the + // log-formatting layer, not silently lost here. + const { chain } = makeChain({ answers: [300_000_000_000n], decimals: 8 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + const usd = feed.weiToUsd(10n ** 9n); + assert.ok(usd !== undefined); + assert.ok((usd as number) > 0, "1 gwei equivalent should not round down to zero"); + feed.stop(); + }); + + it("handles non-standard oracle decimals (e.g. 18)", async () => { + // ETH/USD = $3000 with 18 decimals → raw answer 3000e18. + const { chain } = makeChain({ answers: [3000n * 10n ** 18n], decimals: 18 }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.refresh(); + assert.equal(feed.weiToUsd(10n ** 18n), 3000); + feed.stop(); + }); + }); + + it("start() runs an immediate read and is idempotent", async () => { + const { chain, calls } = makeChain({ answers: [3000_00000000n] }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + await feed.start(); + await feed.start(); // no-op + feed.stop(); + assert.equal(calls.latest, 1, "exactly one eager read at boot"); + }); + + it("stop() is idempotent", () => { + const { chain } = makeChain({ answers: [] }); + const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); + feed.stop(); + feed.stop(); + }); +}); diff --git a/keeper/tests/runtime/balanceMonitor.test.ts b/keeper/tests/runtime/balanceMonitor.test.ts new file mode 100644 index 0000000..6616461 --- /dev/null +++ b/keeper/tests/runtime/balanceMonitor.test.ts @@ -0,0 +1,156 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address } from "viem"; +import { BalanceMonitor } from "../../src/runtime/balanceMonitor.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const SIGNER: Address = "0x00000000000000000000000000000000000000A1"; + +interface LogCall { + level: "info" | "warn" | "error"; + 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: () => undefined, + trace: () => undefined, + fatal: () => undefined, + child: () => logger, + } as unknown as pino.Logger; + return { logger, calls }; +} + +function makeChain(getBalance: () => Promise): Chain { + return { + account: { address: SIGNER }, + publicClient: { + getBalance, + }, + walletClient: {}, + } as unknown as Chain; +} + +function makeConfig(overrides: Partial = {}): Config { + return { + runtime: { + sweepIntervalMs: 60_000, + healthPort: 0, + logLevel: "info", + balanceCheckIntervalMs: 1_000_000, // intervals never fire in tests + balanceLowWei: 10_000_000_000_000_000n, // 10 mETH + balanceCriticalWei: 1_000_000_000_000_000n, // 1 mETH + ...overrides, + }, + } as Config; +} + +describe("BalanceMonitor", () => { + it("logs INFO when balance is comfortably above the low threshold", async () => { + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain(async () => 5n * 10n ** 17n); // 0.5 ETH + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.check(); + monitor.stop(); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.level, "info"); + assert.match(calls[0]?.msg ?? "", /balance OK/); + // Operator-readable units in the log context — wei is too long to + // eyeball at 4 a.m., we want both representations present. + assert.ok(typeof calls[0]?.ctx.balanceWei === "string"); + assert.ok(typeof calls[0]?.ctx.balanceEth === "string"); + }); + + it("logs WARN when balance dips below the low threshold but stays above critical", async () => { + const { logger, calls } = makeRecordingLogger(); + // 5 mETH — between low (10) and critical (1) + const chain = makeChain(async () => 5n * 10n ** 15n); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.check(); + monitor.stop(); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.level, "warn"); + assert.match(calls[0]?.msg ?? "", /balance low/); + }); + + it("logs ERROR when balance drops below the critical threshold", async () => { + const { logger, calls } = makeRecordingLogger(); + // 0.5 mETH — well under critical (1 mETH) + const chain = makeChain(async () => 5n * 10n ** 14n); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.check(); + monitor.stop(); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.level, "error"); + assert.match(calls[0]?.msg ?? "", /CRITICAL/); + }); + + it("treats a zero balance as critical", async () => { + // Boundary check — wallet drained completely should still surface + // as ERROR, not silently skipped because of a strict-less-than bug. + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain(async () => 0n); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.check(); + monitor.stop(); + assert.equal(calls[0]?.level, "error"); + }); + + it("does not throw when getBalance fails — logs a warn and returns undefined", async () => { + // RPC blip should not take the keeper down. The monitor runs on a + // setInterval whose unhandled rejection would crash the process. + const { logger, calls } = makeRecordingLogger(); + const chain = makeChain(async () => { + throw new Error("connect ETIMEDOUT alchemy"); + }); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + const result = await monitor.check(); + monitor.stop(); + assert.equal(result, undefined); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.level, "warn"); + assert.match(calls[0]?.msg ?? "", /balance check failed/); + }); + + it("start() runs an immediate check and is idempotent", async () => { + // Eager initial check is the point — operators want a balance signal + // at boot, not one full interval later. + const { logger, calls } = makeRecordingLogger(); + let getBalanceCount = 0; + const chain = makeChain(async () => { + getBalanceCount++; + return 1n * 10n ** 18n; + }); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.start(); + await monitor.start(); // second start is a no-op, must NOT trigger another check + monitor.stop(); + assert.equal(getBalanceCount, 1, "exactly one check on boot, second start is a no-op"); + assert.equal(calls.length, 1); + }); + + it("stop() clears the interval and is idempotent", async () => { + const { logger } = makeRecordingLogger(); + const chain = makeChain(async () => 1n * 10n ** 18n); + const monitor = new BalanceMonitor(chain, makeConfig(), logger); + await monitor.start(); + monitor.stop(); + monitor.stop(); // must not throw + }); +}); diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index 08bda3f..8d317b6 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -1,48 +1,128 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import pino from "pino"; +import type { Address } from "viem"; import { Healthcheck } from "../../src/runtime/healthcheck.ts"; import type { Config } from "../../src/config.ts"; import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; import type { CoordinatorQueue } from "../../src/coordinator/queue.ts"; import type { ParticipantTracker } from "../../src/discovery/tracker.ts"; import type { PriceFeed } from "../../src/oracle/priceFeed.ts"; -import type { PredictiveCoordinator } from "../../src/predict/coordinator.ts"; +import type { + PredictedThresholds, + PredictiveCoordinator, +} from "../../src/predict/coordinator.ts"; +import type { AccountHealth } from "../../src/pme/health.ts"; const silentLogger = pino({ level: "silent" }); +const SIGNER: Address = "0x000000000000000000000000000000000000005C"; +const STUB_CONFIG: Config = { + chain: { + network: "hardhat", + rpcUrl: "http://stub", + discoveryMode: "events", + backfillChunkSize: 10_000n, + }, + vault: { address: "0x0000000000000000000000000000000000000001" }, + perps: { address: "0x0000000000000000000000000000000000000002" }, + futures: { address: "0x0000000000000000000000000000000000000003" }, + pme: { address: "0x0000000000000000000000000000000000000004" }, + oracle: { + hashpriceUsdcAddress: "0x0000000000000000000000000000000000000005", + btcUsdcFeedAddress: "0x0000000000000000000000000000000000000006", + priceMoveTriggerBps: 0, + }, + keeper: { + privateKey: "0x" + "00".repeat(32) as `0x${string}`, + dryRun: false, + minProfitMargin: 0n, + }, + alerts: { dedupeMs: 0, imWarnUtilization: 0.8, imCriticalUtilization: 0.95 }, + triggers: { webhookPort: 0 }, + coordinator: { maxConcurrentAccounts: 1, confirmationBlocks: 0 }, + runtime: { + sweepIntervalMs: 60_000, + healthPort: 0, + logLevel: "warn", + balanceCheckIntervalMs: 300_000, + balanceLowWei: 10_000_000_000_000_000n, + balanceCriticalWei: 1_000_000_000_000_000n, + }, + outdatedOrders: { + sweepIntervalMs: 0, + maxBatchSize: 50, + }, + delivery: { + enabled: false, + blameSeller: true, + sweepIntervalMs: 60_000, + settleDelayMs: 0, + bootstrapUsers: [], + maxBatchSize: 50, + }, +}; + interface Knobs { executorRunning: boolean; - trackedUsers: number; inflight: number; - queueDepth: number; - predictedUsers?: number; - predictorInflight?: number; + /** Tracked roster — `tracker.size()` mirrors the array length. */ + trackedList?: readonly Address[]; + /** Underwater queue, head-first. Drives `peek()` and `snapshot()`. */ + queueEntries?: ReadonlyArray<{ user: Address; mmSurplus: bigint }>; + /** When defined, the predictor is wired with these per-user thresholds. */ + predictor?: { + /** Per-user combined thresholds returned by `predictor.thresholds()`. */ + thresholds?: readonly PredictedThresholds[]; + /** Users with an in-flight predictive rebuild. */ + inflight?: readonly Address[]; + }; currentPrice?: bigint; } function makeStubs(knobs: Knobs) { - const config = { runtime: { healthPort: 0 } } as Config; // 0 = ephemeral port - const tracker = { size: () => knobs.trackedUsers } as unknown as ParticipantTracker; + const trackedList = knobs.trackedList ?? []; + const tracker = { + size: () => trackedList.length, + list: () => [...trackedList], + } as unknown as ParticipantTracker; const executor = { isRunning: () => knobs.executorRunning, inflightCount: () => knobs.inflight, } as unknown as CoordinatorExecutor; - const queue = { size: () => knobs.queueDepth } as unknown as CoordinatorQueue; + const queueEntries = knobs.queueEntries ?? []; + const queue = { + size: () => queueEntries.length, + peek: () => + queueEntries[0] === undefined + ? undefined + : ({ + user: queueEntries[0].user, + mmSurplus: queueEntries[0].mmSurplus, + } as AccountHealth), + snapshot: () => + queueEntries.map( + (e) => ({ user: e.user, mmSurplus: e.mmSurplus }) as AccountHealth, + ), + } as unknown as CoordinatorQueue; const predictor = - knobs.predictedUsers !== undefined + knobs.predictor !== undefined ? ({ - size: () => knobs.predictedUsers ?? 0, + // size/warnSize/critSize/inflight are still consumed elsewhere + // (index.ts, predict tests). Not asserted here directly. + size: () => knobs.predictor?.thresholds?.length ?? 0, warnSize: () => 0, critSize: () => 0, - inflight: () => knobs.predictorInflight ?? 0, + inflight: () => knobs.predictor?.inflight?.length ?? 0, + inflightUsers: () => [...(knobs.predictor?.inflight ?? [])], + thresholds: () => [...(knobs.predictor?.thresholds ?? [])], } as unknown as PredictiveCoordinator) : undefined; const priceFeed = knobs.currentPrice !== undefined ? ({ current: () => knobs.currentPrice } as unknown as PriceFeed) : undefined; - return { config, tracker, executor, queue, predictor, priceFeed }; + return { config: STUB_CONFIG, tracker, executor, queue, predictor, priceFeed }; } /** Reads the listening port back off the underlying http.Server. */ @@ -52,39 +132,97 @@ function portOf(hc: Healthcheck): number { } describe("runtime/healthcheck: snapshot", () => { - it("includes the predictor + priceFeed when wired", () => { + it("returns tracked / underwater / per-user predicted thresholds when wired", () => { + const a: Address = "0x00000000000000000000000000000000000000a1"; + const b: Address = "0x00000000000000000000000000000000000000a2"; + const c: Address = "0x00000000000000000000000000000000000000a3"; + const thresholds: PredictedThresholds[] = [ + { + user: a, + liq: { down: "1000", up: null }, + warn: { down: "1500", up: null }, + crit: { down: "1200", up: null }, + }, + { + user: b, + liq: { down: null, up: null }, + warn: { down: null, up: null }, + crit: { down: "9999", up: null }, + }, + ]; const { config, tracker, executor, queue, predictor, priceFeed } = makeStubs({ executorRunning: true, - trackedUsers: 3, inflight: 0, - queueDepth: 1, - predictedUsers: 2, - predictorInflight: 1, + trackedList: [a, b, c], + queueEntries: [ + { user: b, mmSurplus: -42_000_000n }, // most underwater → head + { user: c, mmSurplus: -1_000_000n }, + ], + predictor: { thresholds, inflight: [c] }, currentPrice: 100_000_000n, }); - const hc = new Healthcheck(config, tracker, executor, queue, silentLogger, predictor, priceFeed); + const hc = new Healthcheck( + config, + SIGNER, + tracker, + executor, + queue, + silentLogger, + predictor, + priceFeed, + ); const snap = hc.snapshot(); assert.equal(snap.executorRunning, 1); - assert.equal(snap.trackedUsers, 3); - assert.equal(snap.queueDepth, 1); - assert.equal(snap.predictedUsers, 2); - assert.equal(snap.predictorInflight, 1); + assert.equal(snap.queueDepth, 2); + assert.equal(snap.queueHeadUser, b); + assert.equal(snap.queueHeadMmDeficit, "42000000"); assert.equal(snap.currentPrice, "100000000"); + assert.deepEqual(snap.trackedUsers, [a, b, c]); + assert.deepEqual(snap.underwater, [ + { user: b, mmDeficit: "42000000" }, + { user: c, mmDeficit: "1000000" }, + ]); + assert.deepEqual(snap.predictedThresholds, thresholds); + assert.deepEqual(snap.predictorInflight, [c]); }); - it("zero-fills predictor metrics when not wired (legacy boot path)", () => { + it("returns empty roster arrays when predictor / priceFeed are not wired", () => { const { config, tracker, executor, queue } = makeStubs({ executorRunning: false, - trackedUsers: 0, inflight: 0, - queueDepth: 0, }); - const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); const snap = hc.snapshot(); assert.equal(snap.executorRunning, 0); - assert.equal(snap.predictedUsers, 0); - assert.equal(snap.predictorInflight, 0); + assert.deepEqual(snap.trackedUsers, []); + assert.deepEqual(snap.underwater, []); + assert.deepEqual(snap.predictedThresholds, []); + assert.deepEqual(snap.predictorInflight, []); assert.equal(snap.currentPrice, null); + assert.equal(snap.queueHeadUser, null); + assert.equal(snap.queueHeadMmDeficit, 0); + }); +}); + +describe("runtime/healthcheck: info", () => { + it("reports network, signer, and every contract address", () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: true, + inflight: 0, + }); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); + assert.deepEqual(hc.info(), { + network: "hardhat", + discoveryMode: "events", + dryRun: "false", + signer: SIGNER, + vault: config.vault.address, + perps: config.perps.address, + futures: config.futures.address, + pme: config.pme.address, + hashpriceUsdcFeed: config.oracle.hashpriceUsdcAddress, + btcUsdcFeed: config.oracle.btcUsdcFeedAddress, + }); }); }); @@ -94,19 +232,53 @@ describe("runtime/healthcheck: snapshot", () => { * Use a small `withServer` helper instead so each test owns its * setup/teardown via try/finally. */ +const SERVER_TRACKED: Address[] = [ + "0x00000000000000000000000000000000000000b1", + "0x00000000000000000000000000000000000000b2", + "0x00000000000000000000000000000000000000b3", + "0x00000000000000000000000000000000000000b4", + "0x00000000000000000000000000000000000000b5", +]; + +const SERVER_THRESHOLDS: PredictedThresholds[] = [ + { + user: SERVER_TRACKED[0] as Address, + liq: { down: "100", up: null }, + warn: { down: "200", up: null }, + crit: { down: "150", up: null }, + }, + { + user: SERVER_TRACKED[2] as Address, + liq: { down: null, up: "5000" }, + warn: { down: null, up: "4500" }, + crit: { down: null, up: "4900" }, + }, +]; + async function withServer( fn: (port: number) => Promise, ): Promise { const { config, tracker, executor, queue, predictor, priceFeed } = makeStubs({ executorRunning: true, - trackedUsers: 5, inflight: 0, - queueDepth: 2, - predictedUsers: 4, - predictorInflight: 0, + trackedList: SERVER_TRACKED, + queueEntries: [ + { user: SERVER_TRACKED[1] as Address, mmSurplus: -7n }, + { user: SERVER_TRACKED[3] as Address, mmSurplus: -3n }, + ], + predictor: { thresholds: SERVER_THRESHOLDS, inflight: [] }, currentPrice: 250_000_000n, }); - const hc = new Healthcheck(config, tracker, executor, queue, silentLogger, predictor, priceFeed); + const hc = new Healthcheck( + config, + SIGNER, + tracker, + executor, + queue, + silentLogger, + predictor, + priceFeed, + ); hc.start(); try { return await fn(portOf(hc)); @@ -116,15 +288,25 @@ async function withServer( } describe("runtime/healthcheck: HTTP endpoints", () => { - it("GET /health returns 200 with the snapshot when executor is running", async () => { + it("GET /health returns 200 with the full snapshot + info block when running", async () => { await withServer(async (port) => { const res = await fetch(`http://127.0.0.1:${port}/health`); assert.equal(res.status, 200); const body = (await res.json()) as Record; assert.equal(body.status, "ok"); - assert.equal(body.trackedUsers, 5); - assert.equal(body.predictedUsers, 4); + assert.deepEqual(body.trackedUsers, SERVER_TRACKED); + assert.deepEqual(body.predictedThresholds, SERVER_THRESHOLDS); + assert.deepEqual(body.predictorInflight, []); + assert.deepEqual(body.underwater, [ + { user: SERVER_TRACKED[1], mmDeficit: "7" }, + { user: SERVER_TRACKED[3], mmDeficit: "3" }, + ]); assert.equal(body.currentPrice, "250000000"); + const info = body.info as Record; + assert.equal(info.network, "hardhat"); + assert.equal(info.signer, SIGNER); + assert.equal(info.vault, STUB_CONFIG.vault.address); + assert.equal(info.perps, STUB_CONFIG.perps.address); }); }); @@ -135,10 +317,15 @@ describe("runtime/healthcheck: HTTP endpoints", () => { assert.match(res.headers.get("content-type") ?? "", /text\/plain/); const body = await res.text(); assert.match(body, /keeper_executor_running 1/); + // Address-list snapshot fields collapse to their length in Prometheus. assert.match(body, /keeper_tracked_users 5/); assert.match(body, /keeper_queue_depth 2/); - assert.match(body, /keeper_predicted_users 4/); + assert.match(body, /keeper_predicted_thresholds 2/); + assert.match(body, /keeper_predictor_inflight 0/); assert.match(body, /keeper_oracle_price_token 250000000/); + assert.match(body, /keeper_info\{[^}]*network="hardhat"[^}]*\} 1/); + assert.match(body, new RegExp(`signer="${SIGNER}"`)); + assert.match(body, new RegExp(`vault="${STUB_CONFIG.vault.address}"`)); }); }); @@ -154,11 +341,9 @@ describe("runtime/healthcheck: degraded executor", () => { it("returns 503 when the executor is stopped", async () => { const { config, tracker, executor, queue } = makeStubs({ executorRunning: false, - trackedUsers: 0, inflight: 0, - queueDepth: 0, }); - const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); hc.start(); const port = portOf(hc); try { @@ -174,13 +359,11 @@ describe("runtime/healthcheck: degraded executor", () => { it("/metrics omits keeper_oracle_price_token when the feed is uninitialised", async () => { const { config, tracker, executor, queue } = makeStubs({ executorRunning: true, - trackedUsers: 0, inflight: 0, - queueDepth: 0, - predictedUsers: 0, + predictor: { thresholds: [], inflight: [] }, }); // No priceFeed provided → snapshot returns currentPrice: null. - const hc = new Healthcheck(config, tracker, executor, queue, silentLogger); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); hc.start(); const port = portOf(hc); try { diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts new file mode 100644 index 0000000..aca1639 --- /dev/null +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -0,0 +1,524 @@ +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/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; + deliveryAt: 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 !== "getOrderIds") { + 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 !== "getOrderById") { + 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 `deliveryAt` but include the other fields so tests + // stay close to the real ABI. + return { + isBuy: true, + participant: order.participant, + destURL: "", + pricePerDay: 0n, + deliveryAt: order.deliveryAt, + createdAt: 0n, + }; + }); + }, + 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 deliveryAt 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, deliveryAt: 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, deliveryAt: 5_000n }], // expired + [id2, { participant: USER_A, deliveryAt: 9_999n }], // expired + [id3, { participant: USER_A, deliveryAt: 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, deliveryAt: 5_000n }], + [idB, { participant: USER_B, deliveryAt: 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, deliveryAt: 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 `getOrderById` read and our simulate, the + // user (or a concurrent keeper) closed orderId1, and orderId2 had its + // deliveryAt 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, deliveryAt: 5_000n }], + [id2, { participant: USER_A, deliveryAt: 6_000n }], + [id3, { participant: USER_A, deliveryAt: 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, deliveryAt: 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 getOrderIds 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, deliveryAt: 1n }], + ]); + const blockTimestamp = 10_000n; + + // Custom chain that fails getOrderIds(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 { + isBuy: true, + participant: order.participant, + destURL: "", + pricePerDay: 0n, + deliveryAt: order.deliveryAt, + createdAt: 0n, + }; + }); + }, + 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("getOrderIds 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/tx/gasCost.test.ts b/keeper/tests/tx/gasCost.test.ts new file mode 100644 index 0000000..0d2982b --- /dev/null +++ b/keeper/tests/tx/gasCost.test.ts @@ -0,0 +1,79 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { TransactionReceipt } from "viem"; +import { formatGasCost } from "../../src/tx/gasCost.ts"; +import type { EthUsdFeed } from "../../src/oracle/ethUsdFeed.ts"; + +/** + * Stand-in for the real EthUsdFeed — we only exercise `weiToUsd` here. + * Builds either a deterministic ETH price (typed) or a "never primed" feed + * that returns `undefined` so we can verify the absent-USD code path. + */ +function makeFeed(weiToUsd: (wei: bigint) => number | undefined): EthUsdFeed { + return { weiToUsd } as unknown as EthUsdFeed; +} + +function receipt(gasUsed: bigint, effectiveGasPrice: bigint) { + // Cast through a partial — formatGasCost only reads two fields and we + // deliberately don't construct the rest of TransactionReceipt. + return { gasUsed, effectiveGasPrice } as unknown as TransactionReceipt; +} + +describe("formatGasCost", () => { + it("returns gasUsed (number), gwei-formatted gas price and ether-formatted cost", () => { + // 100k gas at 2 gwei = 200_000 * 2e9 = 4e14 wei = 0.0002 ETH + const fields = formatGasCost(receipt(200_000n, 2_000_000_000n)); + assert.equal(fields.gasUsed, 200_000); + assert.equal(fields.gasPriceGwei, "2"); + assert.equal(fields.gasCostEth, "0.0004"); + assert.equal(fields.gasCostUsd, undefined, "no feed → no USD field"); + }); + + it("omits gasCostUsd when feed is provided but uninitialised", () => { + // Pre-feed-priming case (e.g. tx mined before the first refresh). + const fields = formatGasCost( + receipt(100_000n, 1_000_000_000n), + makeFeed(() => undefined), + ); + assert.equal(fields.gasCostUsd, undefined); + assert.equal(fields.gasCostEth, "0.0001"); + }); + + it("includes a rounded gasCostUsd when the feed produces a value", () => { + // 100k * 1 gwei = 1e14 wei. Pretend ETH/USD = $3000; cost = 0.0001 * 3000 = $0.3 + const fields = formatGasCost( + receipt(100_000n, 1_000_000_000n), + makeFeed((wei) => Number(wei) * 3000 / 1e18), + ); + assert.equal(fields.gasCostUsd, 0.3); + }); + + it("rounds gasCostUsd to 6 decimal places so log output stays terse", () => { + // Pick a value that produces noisy fp trailing digits in JSON output. + const fields = formatGasCost( + receipt(1n, 1n), + makeFeed(() => 0.123456789), + ); + assert.equal(fields.gasCostUsd, 0.123457); + }); + + it("handles a literally-zero-cost receipt without dividing by zero or returning NaN", () => { + const fields = formatGasCost( + receipt(0n, 0n), + makeFeed((wei) => (wei === 0n ? 0 : 1)), + ); + assert.equal(fields.gasUsed, 0); + assert.equal(fields.gasPriceGwei, "0"); + assert.equal(fields.gasCostEth, "0"); + assert.equal(fields.gasCostUsd, 0); + }); + + it("tolerates a partial receipt with null gasUsed / effectiveGasPrice (RPC fallback)", () => { + // Some providers return `null` here on freshly-mined txs; we must + // not crash a tx confirmation path on a cosmetic field. + const partial = { gasUsed: null, effectiveGasPrice: null } as unknown as TransactionReceipt; + const fields = formatGasCost(partial); + assert.equal(fields.gasUsed, 0); + assert.equal(fields.gasCostEth, "0"); + }); +}); diff --git a/keeper/tests/tx/unstick.test.ts b/keeper/tests/tx/unstick.test.ts new file mode 100644 index 0000000..8229288 --- /dev/null +++ b/keeper/tests/tx/unstick.test.ts @@ -0,0 +1,280 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import pino from "pino"; +import type { Address, Hex } from "viem"; +import { + unstickPendingNonces, + withUnstickRetry, + isReplacementUnderpriced, +} from "../../src/tx/unstick.ts"; +import type { Chain } from "../../src/chain.ts"; + +const SIGNER: Address = "0x000000000000000000000000000000000000A157"; + +const silentLogger = pino({ level: "silent" }); + +interface ChainStubOpts { + /** Sequence of `getTransactionCount` answers per blockTag. Cycled if exhausted. */ + latestNonces?: number[]; + pendingNonces?: number[]; + fees?: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }; + /** Per-call hook for sendTransaction; throws to simulate RPC errors. */ + onSendTransaction?: (req: unknown, callIdx: number) => Promise; +} + +interface RecordedSend { + nonce: number; + to: Address; + value: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} + +function makeChain(opts: ChainStubOpts = {}) { + const sends: RecordedSend[] = []; + const txCountCalls: Array<"latest" | "pending"> = []; + let latestIdx = 0; + let pendingIdx = 0; + const latestSeq = opts.latestNonces ?? [10]; + const pendingSeq = opts.pendingNonces ?? [10]; + let sendIdx = 0; + + const chain = { + account: { address: SIGNER }, + publicClient: { + getTransactionCount: async ({ blockTag }: { blockTag: "latest" | "pending" }) => { + txCountCalls.push(blockTag); + if (blockTag === "latest") { + const v = latestSeq[Math.min(latestIdx, latestSeq.length - 1)] as number; + latestIdx++; + return v; + } + const v = pendingSeq[Math.min(pendingIdx, pendingSeq.length - 1)] as number; + pendingIdx++; + return v; + }, + estimateFeesPerGas: async () => + opts.fees ?? { + maxFeePerGas: 1_000_000_000n, // 1 gwei + maxPriorityFeePerGas: 100_000_000n, // 0.1 gwei + }, + }, + walletClient: { + chain: null, + sendTransaction: async (req: { + nonce: number; + to: Address; + value: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + }) => { + const idx = sendIdx++; + if (opts.onSendTransaction !== undefined) return opts.onSendTransaction(req, idx); + sends.push({ + nonce: req.nonce, + to: req.to, + value: req.value, + maxFeePerGas: req.maxFeePerGas, + maxPriorityFeePerGas: req.maxPriorityFeePerGas, + }); + return ("0x" + idx.toString(16).padStart(64, "0")) as Hex; + }, + }, + } as unknown as Chain; + + return { chain, sends, txCountCalls }; +} + +describe("isReplacementUnderpriced", () => { + it("matches the exact error string viem surfaces from Alchemy / Geth", () => { + const err = new Error("Some wrapper text\nreplacement transaction underpriced"); + assert.equal(isReplacementUnderpriced(err), true); + }); + + it("matches the bare 'transaction underpriced' variant from non-replacement cases", () => { + const err = new Error("transaction underpriced (gas tip too low)"); + assert.equal(isReplacementUnderpriced(err), true); + }); + + it("matches errors that surface the cause via viem's `details` field", () => { + // Viem's ContractFunctionExecutionError flattens the RPC error body + // into a `details` property; the top-level `message` may not contain + // the underpriced string at all. + const err = Object.assign(new Error("ContractFunctionExecutionError"), { + details: "replacement transaction underpriced", + }); + assert.equal(isReplacementUnderpriced(err), true); + }); + + it("does not match unrelated errors", () => { + assert.equal(isReplacementUnderpriced(new Error("nonce too low")), false); + assert.equal(isReplacementUnderpriced(new Error("insufficient funds for gas")), false); + assert.equal(isReplacementUnderpriced("not even an Error"), false); + assert.equal(isReplacementUnderpriced(undefined), false); + }); +}); + +describe("unstickPendingNonces", () => { + it("is a no-op when pending == latest (nothing stuck)", async () => { + const { chain, sends } = makeChain({ latestNonces: [42], pendingNonces: [42] }); + const cancelled = await unstickPendingNonces(chain, silentLogger); + assert.equal(cancelled, 0); + assert.equal(sends.length, 0, "no cancellation broadcasts when mempool is clear"); + }); + + it("cancels every nonce in [latest, pending) with a 3x-bumped self-transfer", async () => { + // 3 stuck nonces (latest=10, pending=13) → 3 cancellations. + // After we send the cancels, the polling loop reads latest again + // and sees it caught up to pending — exits cleanly. + const { chain, sends } = makeChain({ + latestNonces: [10, 13], + pendingNonces: [13], + fees: { maxFeePerGas: 2_000_000_000n, maxPriorityFeePerGas: 200_000_000n }, + }); + const cancelled = await unstickPendingNonces(chain, silentLogger); + assert.equal(cancelled, 3); + assert.deepEqual( + sends.map((s) => s.nonce), + [10, 11, 12], + "covers every stuck nonce in order", + ); + // Each cancel is a 0-value self-transfer at 3x the estimated fees. + // Locking in the multiplier here so a future tweak from 3x → 1.5x + // can't regress without breaking the test (mempools that demand + // big bumps to evict have bitten us before). + for (const s of sends) { + assert.equal(s.to, SIGNER); + assert.equal(s.value, 0n); + assert.equal(s.maxFeePerGas, 6_000_000_000n); + assert.equal(s.maxPriorityFeePerGas, 600_000_000n); + } + }); + + it("skips nonces that already cleared (`nonce too low`) without aborting the rest", async () => { + // Race: between our pending-count read and our cancel send, the + // first stuck tx mined on its own. The cancel for that nonce now + // gets `nonce too low` from the node — we must skip it and keep + // cancelling the others, not bail out. + const sends: RecordedSend[] = []; + const { chain } = makeChain({ + latestNonces: [10, 13], + pendingNonces: [13], + onSendTransaction: async (req, idx) => { + if (idx === 0) throw new Error("nonce too low"); + const r = req as RecordedSend; + sends.push({ + nonce: r.nonce, + to: r.to, + value: r.value, + maxFeePerGas: r.maxFeePerGas, + maxPriorityFeePerGas: r.maxPriorityFeePerGas, + }); + return ("0x" + idx.toString(16).padStart(64, "0")) as Hex; + }, + }); + // No exception, no abort — successful cancels still recorded. + const cancelled = await unstickPendingNonces(chain, silentLogger); + // First send returned an error so wasn't recorded into `sends`, + // but the loop kept going for nonces 11 and 12. + assert.equal(cancelled, 2); + assert.deepEqual( + sends.map((s) => s.nonce), + [11, 12], + ); + }); + + it("refuses to cancel more than the safety cap to defend against a misreporting RPC", async () => { + // 33 stuck > 32 cap → throw. Without this guard, a buggy provider + // claiming "you have 100M pending txs" would drain the wallet on + // 21k-gas cancellations. + const { chain, sends } = makeChain({ + latestNonces: [0], + pendingNonces: [33], + }); + await assert.rejects( + () => unstickPendingNonces(chain, silentLogger), + /refusing to process 33 stuck nonces/, + ); + assert.equal(sends.length, 0, "must not broadcast anything when the cap is exceeded"); + }); + + it("uses pending blockTag when reading the upper nonce bound, not just latest", async () => { + // Important RPC contract assertion: latest=N, pending=N+K. If we + // accidentally read both as latest we'd never cancel anything. + const { chain, txCountCalls } = makeChain({ + latestNonces: [5, 7], + pendingNonces: [7], + }); + await unstickPendingNonces(chain, silentLogger); + assert.ok(txCountCalls.includes("pending"), "must query pending blockTag"); + assert.ok(txCountCalls.includes("latest"), "must query latest blockTag"); + }); +}); + +describe("withUnstickRetry", () => { + it("returns the write result directly when no error occurs", async () => { + const { chain } = makeChain(); + let called = 0; + const out = await withUnstickRetry(chain, silentLogger, async () => { + called++; + return "0xabc" as Hex; + }); + assert.equal(out, "0xabc"); + assert.equal(called, 1, "no retry when first attempt succeeds"); + }); + + it("propagates errors that are not `replacement transaction underpriced` without retrying", async () => { + // Permanent errors (insufficient funds, ABI mismatch, signature + // mismatch) must not be papered over with an unstick — that would + // silently drain gas on every sweep. + const { chain } = makeChain(); + let called = 0; + await assert.rejects( + () => + withUnstickRetry(chain, silentLogger, async () => { + called++; + throw new Error("insufficient funds for gas"); + }), + /insufficient funds/, + ); + assert.equal(called, 1, "no retry for non-recoverable errors"); + }); + + it("on `replacement transaction underpriced` runs unstick then retries the write exactly once", async () => { + // First attempt throws the underpriced error → triggers unstick → + // second attempt is the retry (here it succeeds). The whole point + // of the helper is to make this happen invisibly to callers. + const { chain, sends } = makeChain({ + latestNonces: [5, 8], + pendingNonces: [8], + }); + let writeAttempts = 0; + const out = await withUnstickRetry(chain, silentLogger, async () => { + writeAttempts++; + if (writeAttempts === 1) throw new Error("replacement transaction underpriced"); + return "0xdeadbeef" as Hex; + }); + assert.equal(out, "0xdeadbeef"); + assert.equal(writeAttempts, 2, "exactly one retry"); + assert.equal(sends.length, 3, "unstick cancelled all 3 pending nonces between attempts"); + }); + + it("does not retry more than once — a second underpriced error surfaces", async () => { + // If unstick + 1 retry didn't fix it, something structural is + // wrong (RPC reporting bad nonces, another writer using the same + // key from outside the keeper). We must NOT loop forever — let + // the caller see the error so the next sweep can decide what to + // do, or so the operator gets a visible signal. + const { chain } = makeChain({ latestNonces: [5, 5], pendingNonces: [5] }); + let writeAttempts = 0; + await assert.rejects( + () => + withUnstickRetry(chain, silentLogger, async () => { + writeAttempts++; + throw new Error("replacement transaction underpriced"); + }), + /replacement transaction underpriced/, + ); + assert.equal(writeAttempts, 2, "exactly two attempts (initial + one retry), no infinite loop"); + }); +}); diff --git a/keeper/tests/venues/perps.test.ts b/keeper/tests/venues/perps.test.ts index 5984c0b..f878b1c 100644 --- a/keeper/tests/venues/perps.test.ts +++ b/keeper/tests/venues/perps.test.ts @@ -97,7 +97,7 @@ describe("perps venue: readPositions", () => { }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const positions = await venue.readPositions(USER); - assert.equal(positions.length, 0); + assert.equal(positions.length, 1); }); it("computes unrealizedLoss=0 and notional=marketPrice*qty for a profitable long", async () => { @@ -105,10 +105,7 @@ describe("perps venue: readPositions", () => { const entryPrice = 100n; const marketPrice = 150n; // up → long is in profit, no loss const chain = makeChainStub({ - multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, - marketPrice, - ], + multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -122,10 +119,7 @@ describe("perps venue: readPositions", () => { const entryPrice = 200n; const marketPrice = 150n; // -50 per contract × 3 contracts = 150 loss const chain = makeChainStub({ - multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, - marketPrice, - ], + multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -139,10 +133,7 @@ describe("perps venue: readPositions", () => { const entryPrice = 100n; const marketPrice = 130n; // +30 against the short × 4 = 120 loss const chain = makeChainStub({ - multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, - marketPrice, - ], + multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -153,10 +144,7 @@ describe("perps venue: readPositions", () => { it("synthesises a deterministic positionId from the user address (bytes32(user))", async () => { const chain = makeChainStub({ - multicall: () => [ - { netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, - 100n, - ], + multicall: () => [{ netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, 100n], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); From b4cf27547141b0d00454f87d6f274b9b985b75e7 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 25 May 2026 17:39:41 +0500 Subject: [PATCH 025/180] =?UTF-8?q?market-maker:=20fix=20type=20errors=20?= =?UTF-8?q?=E2=80=94=20Lot=20events=20replace=20Position=20events,=20Order?= =?UTF-8?q?Closed=20drops=20participant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace PositionCreated/PositionClosed with LotCreated/LotClosed following the contract rename (Futures.sol) - OrderClosed no longer emits participant per the contract; make participant optional in order-cancelled VenueEvent and identify own orders by cache membership in futures own-orders handler - Guard perps order-cancelled handler against undefined participant --- market-maker/package.json | 9 +- market-maker/pnpm-lock.yaml | 124 +++++++++++++++--- market-maker/src/adapters/futures/events.ts | 37 ++++-- .../src/adapters/futures/ownOrders.ts | 24 +++- market-maker/src/adapters/perps/instrument.ts | 28 +++- market-maker/src/core/adapter.ts | 52 +++++++- 6 files changed, 222 insertions(+), 52 deletions(-) diff --git a/market-maker/package.json b/market-maker/package.json index 2e37467..affca39 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -8,7 +8,7 @@ }, "scripts": { "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "node": "node --import=amaro/strip --conditions=typescript", "local:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", "local:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", @@ -22,6 +22,7 @@ "pretypecheck": "pnpm node scripts/gen-schemas.ts" }, "dependencies": { + "@sinclair/typebox": "^0.34.49", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amaro": "^1.1.9", @@ -31,12 +32,12 @@ "js-yaml": "^4.1.0", "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts", "pino": "^10.3.1", - "viem": "^2.45.3", - "@sinclair/typebox": "^0.34.49" + "viem": "^2.45.3" }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", + "@typescript/native-preview": "7.0.0-dev.20260525.1", "pino-pretty": "^13.1.3", "typescript": "^5.8.0" }, @@ -47,4 +48,4 @@ "secp256k1@4.0.4" ] } -} \ No newline at end of file +} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index a7e2737..547aaa8 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#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 @@ -48,6 +48,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260525.1 + version: 7.0.0-dev.20260525.1 pino-pretty: specifier: ^13.1.3 version: 13.1.3 @@ -450,10 +453,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} @@ -924,6 +923,53 @@ packages: '@types/secp256k1@4.0.7': resolution: {integrity: sha512-Rcvjl6vARGAKRO6jHeKMatGrvOMGrR/AR11N1x2LqintPCyDZ7NBhrh238Z2VZc7aM7KIwnFpFQ7fnfK4H/9Qw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-x0ClBYc6xQDLXvpRn/zg6SViX/r1F8LXHyfSHmKx4ieiaZiVvGsEww/qzdHind+Y62MIUN3e/XfDFrpRxWDv0g==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-CSHbx6HfM+xXqceGFtG4kcqqoQ5xjT1BHO0bqLfLeQtKlMlze59dIV2DbOb5Aj6wm2ACTKU4K9aurJDdHARx1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-0DFKd3EuZ/Z0/mB114mATrlRxQUo7rcpXYgd5CJN7y1dbIgkavbjVamzzJKt3s42tkJGfdys83w6aIHDu6fykw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-hY2EVAaGc1bsaxthJiNUbzn6ESkMSLBiWRCNhQl8XdhDWew8KhKCjw4DHe0lAYSdxLJBe6fCPpcFjDnoSowBxA==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-GhC0kXeYxn55Rk3klmWET/Y033AHeMzLBMO58yP7R8m5ZdGiBisejDZnvttzczYJtgT42LNOtVmbtsG/+R8XWw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-L2+bsx73FyuEzLNgybtIxhnT9lYYAh9rTRFWZ4wZlJg44DGstjgz4FBKVHBO/cm3Hz7YNWeJESrB9ROUNbffPg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-xJCdFz9smVQVpXYW0vZZJsM0GIANPqSt8eMDRYfDY6M/BcXNXYOAt7tsxnSRyYWnFf9Ci7wKNRZaihZrDJ2m6A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260525.1': + resolution: {integrity: sha512-hZ3BSv2Q45UZPC8mi7YIWNRirdT99eipknsgigp7GfczYisSjfn6/XERgUfspHDWI7k4zM/KK4j/peHWtpqcmg==} + engines: {node: '>=16.20.0'} + hasBin: true + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -1090,6 +1136,9 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1496,8 +1545,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c} version: 1.0.0 engines: {node: 24.x} @@ -1859,6 +1908,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: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2454,8 +2507,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} engines: {node: '>=8.3.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3423,8 +3476,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': @@ -4099,6 +4150,37 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260525.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260525.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260525.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260525.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260525.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260525.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260525.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260525.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260525.1 + '@yarnpkg/lockfile@1.1.0': {} abitype@1.2.3(typescript@5.9.3): @@ -4262,6 +4344,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4481,13 +4567,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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 '@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/ef77952259de3abc1b219e18618ef3fb49ebce79 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' @@ -4761,7 +4847,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/64e5bc5af2397111d4b3392a9edbbd18f26a4474#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#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 @@ -4772,7 +4858,7 @@ snapshots: '@safe-global/types-kit': 2.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 - 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 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -4883,7 +4969,7 @@ snapshots: tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 - ws: 7.5.10 + ws: 7.5.11 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -5148,7 +5234,7 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.1 minimatch@9.0.9: dependencies: @@ -5187,6 +5273,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: {} @@ -5747,7 +5835,7 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.10: {} + ws@7.5.11: {} ws@8.18.0: {} diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 4ce2d01..30ead7f 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -1,10 +1,21 @@ import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; -import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; +import type { + Unsubscribe, + VenueEvent, + VenueEvents, +} from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; export const FUTURES_INSTRUMENT_ID = "futures"; -type FuturesLog = Log; +type FuturesLog = Log< + bigint, + number, + false, + undefined, + false, + typeof FuturesAbi +>; /** Multiplexes one viem watcher across many subscribers. Decode-only. */ export class FuturesVenueEvents implements VenueEvents { @@ -51,7 +62,13 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { switch (log.eventName) { case "OrderCreated": { const { orderId, participant, pricePerDay, isBuy } = log.args; - if (!orderId || !participant || pricePerDay === undefined || isBuy === undefined) return null; + if ( + !orderId || + !participant || + pricePerDay === undefined || + isBuy === undefined + ) + return null; return { type: "order-created", orderId, @@ -63,26 +80,24 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { }; } case "OrderClosed": { - const { orderId, participant } = log.args; - if (!orderId || !participant) return null; + const { orderId } = log.args; + if (!orderId) return null; return { type: "order-cancelled", orderId, - participant, instrumentId: FUTURES_INSTRUMENT_ID, }; } - case "PositionCreated": { - const { seller, buyer } = log.args; - if (!seller || !buyer) return null; - // Emit a position-changed for both sides; consumers filter by participant. + case "LotCreated": { + const { lotId, seller, buyer } = log.args; + if (!lotId || !seller || !buyer) return null; return { type: "position-changed", participant: seller, instrumentId: FUTURES_INSTRUMENT_ID, }; } - case "PositionClosed": + case "LotClosed": return { type: "position-changed", participant: "0x0" as `0x${string}`, diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index daee43b..722a931 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -1,5 +1,10 @@ import type pino from "pino"; -import type { OwnOrder, OwnOrderEvent, OwnOrderSource, Unsubscribe } from "../../core/adapter.ts"; +import type { + OwnOrder, + OwnOrderEvent, + OwnOrderSource, + Unsubscribe, +} from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FUTURES_INSTRUMENT_ID } from "./events.ts"; @@ -63,7 +68,10 @@ export class FuturesOwnOrders implements OwnOrderSource { if (orderIds.length === 0) { this.bootstrapped = true; - this.logger.info({ orders: 0 }, "futures own-orders bootstrapped (empty)"); + this.logger.info( + { orders: 0 }, + "futures own-orders bootstrapped (empty)", + ); return; } @@ -80,7 +88,10 @@ export class FuturesOwnOrders implements OwnOrderSource { for (let i = 0; i < orderIds.length; i++) { const o = orders[i]; - if (!o.participant || o.participant === "0x0000000000000000000000000000000000000000") + if ( + !o.participant || + o.participant === "0x0000000000000000000000000000000000000000" + ) continue; this.cache.set(orderIds[i], { orderId: orderIds[i], @@ -92,7 +103,10 @@ export class FuturesOwnOrders implements OwnOrderSource { } this.bootstrapped = true; - this.logger.info({ orders: this.cache.size }, "futures own-orders bootstrapped"); + this.logger.info( + { orders: this.cache.size }, + "futures own-orders bootstrapped", + ); } private attach(): Unsubscribe { @@ -112,7 +126,7 @@ export class FuturesOwnOrders implements OwnOrderSource { return; } if (evt.type === "order-cancelled") { - if (evt.participant.toLowerCase() !== own) return; + // OrderClosed no longer carries participant; identify own orders by cache. if (!this.cache.has(evt.orderId)) return; this.cache.delete(evt.orderId); this.notify({ type: "removed", orderId: evt.orderId }); diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 532c51b..669d71f 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -51,7 +51,10 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { functionName: "getUserPosition", args: [owner], }); - return { netQuantity: pos.netQuantity, entryPrice: pos.aggregatedEntryPrice }; + return { + netQuantity: pos.netQuantity, + entryPrice: pos.aggregatedEntryPrice, + }; } async getContext(): Promise { @@ -95,7 +98,8 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { // Ensure the venue has a cached spot shock; if not, fall back to the // no-op estimate. The first canPlace call is allowed through optimistically. // The cached value is fetched lazily by `account.imSpotShock()` and cached. - const cached = (this.venue as unknown as { imSpotShockCache?: bigint }).imSpotShockCache; + const cached = (this.venue as unknown as { imSpotShockCache?: bigint }) + .imSpotShockCache; if (!cached) return 0n; const notional = calculateNotional(intent.price, intent.size); return (notional * cached) / 10n ** 18n; @@ -147,7 +151,8 @@ class PerpsBook implements BookSource { functionName: "getOrderBookPrices", args: [depth], }); - if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; + if (bidPrices.length === 0 && askPrices.length === 0) + return { bids: [], asks: [] }; const depthCalls = [ ...bidPrices.map((p) => ({ @@ -163,8 +168,14 @@ class PerpsBook implements BookSource { args: [p, false] as const, })), ]; - const results = await v.publicClient.multicall({ allowFailure: false, contracts: depthCalls }); - const bids: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); + const results = await v.publicClient.multicall({ + allowFailure: false, + contracts: depthCalls, + }); + const bids: DepthLevel[] = bidPrices.map((p, i) => ({ + price: p, + quantity: results[i], + })); const asks: DepthLevel[] = askPrices.map((p, i) => ({ price: p, quantity: results[bidPrices.length + i], @@ -201,7 +212,10 @@ class PerpsOwnOrders implements OwnOrderSource { functionName: "getOrder" as const, args: [id] as const, })); - const results = await v.publicClient.multicall({ allowFailure: false, contracts: calls }); + const results = await v.publicClient.multicall({ + allowFailure: false, + contracts: calls, + }); return orderIds.map((orderId, i) => { const q = results[i].quantity; return { @@ -234,7 +248,7 @@ class PerpsOwnOrders implements OwnOrderSource { return; } case "order-cancelled": { - if (evt.participant.toLowerCase() !== own) return; + if (!evt.participant || evt.participant.toLowerCase() !== own) return; cb({ type: "removed", orderId: evt.orderId }); return; } diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 98e047f..cf30dc6 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -1,4 +1,10 @@ -import type { Account, Chain, PublicClient, Transport, WalletClient } from "viem"; +import type { + Account, + Chain, + PublicClient, + Transport, + WalletClient, +} from "viem"; // ─── Order intents ─────────────────────────────────────────────────────────── @@ -179,11 +185,40 @@ export interface OwnOrderSource { * `OwnOrderSource.list()` (perps). */ export type VenueEvent = - | { type: "order-created"; orderId: `0x${string}`; participant: `0x${string}`; price: bigint; side: Side; size: bigint; instrumentId?: string } - | { type: "order-updated"; orderId: `0x${string}`; participant: `0x${string}`; newSize: bigint; instrumentId?: string } - | { type: "order-cancelled"; orderId: `0x${string}`; participant: `0x${string}`; instrumentId?: string } - | { type: "order-matched"; makerOrderId: `0x${string}`; maker?: `0x${string}`; taker?: `0x${string}`; instrumentId?: string } - | { type: "position-changed"; participant: `0x${string}`; instrumentId?: string }; + | { + type: "order-created"; + orderId: `0x${string}`; + participant: `0x${string}`; + price: bigint; + side: Side; + size: bigint; + instrumentId?: string; + } + | { + type: "order-updated"; + orderId: `0x${string}`; + participant: `0x${string}`; + newSize: bigint; + instrumentId?: string; + } + | { + type: "order-cancelled"; + orderId: `0x${string}`; + participant?: `0x${string}`; + instrumentId?: string; + } + | { + type: "order-matched"; + makerOrderId: `0x${string}`; + maker?: `0x${string}`; + taker?: `0x${string}`; + instrumentId?: string; + } + | { + type: "position-changed"; + participant: `0x${string}`; + instrumentId?: string; + }; export interface VenueEvents { subscribe(cb: (event: VenueEvent) => void): Unsubscribe; @@ -266,5 +301,8 @@ export interface VenueAdapter { * Batch cancels/creates in one tx. Returns tx hash. Implementations route * through the venue contract's multicall function. */ - multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint }): Promise<`0x${string}`>; + multicall( + calls: `0x${string}`[], + opts: { maxFeePerGas?: bigint }, + ): Promise<`0x${string}`>; } From 73d495ad6515bc02a29cb7f620271f21fab8cb8a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 25 May 2026 18:14:46 +0500 Subject: [PATCH 026/180] Multicall batch size + order aggregation Add configurable multicall batch size and aggregate own orders in health endpoint --- market-maker/src/adapters/futures/index.ts | 5 +- .../src/adapters/futures/instrument.ts | 91 +++++++++++++++---- .../src/adapters/futures/ownOrders.ts | 32 +++++-- market-maker/src/adapters/futures/venue.ts | 85 ++++++++++++++--- market-maker/src/apps/futures/config.ts | 64 +++++++++---- market-maker/src/apps/futures/main.ts | 32 +++++-- market-maker/src/core/healthcheck.ts | 79 ++++++++++++---- 7 files changed, 304 insertions(+), 84 deletions(-) diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index da07fa9..ed45dc1 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -8,6 +8,7 @@ export interface CreateFuturesVenueOpts { wallet: WalletContext; address: `0x${string}`; multicall3Address?: `0x${string}`; + multicallBatchSize?: number; logger: pino.Logger; } @@ -16,7 +17,9 @@ export interface CreateFuturesVenueOpts { * * matchingMode = "exact" — fills only happen when prices coincide exactly. */ -export async function createFuturesVenue(opts: CreateFuturesVenueOpts): Promise { +export async function createFuturesVenue( + opts: CreateFuturesVenueOpts, +): Promise { return new FuturesVenueAdapter(opts); } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 7574de3..7b5d166 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -29,8 +29,9 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { this.venue = venue; - this.book = new FuturesBook(this); - this.ownOrders = new FuturesOwnOrders(venue, logger); + const batchSize = venue.multicallBatchSize; + this.book = new FuturesBook(this, batchSize); + this.ownOrders = new FuturesOwnOrders(venue, logger, batchSize); } async getIndexPrice(): Promise { @@ -73,7 +74,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { abi: FuturesAbi, functionName: "getDeliveryDates", }); - if (deliveryDates.length === 0) throw new Error("futures contract returned no delivery dates"); + if (deliveryDates.length === 0) + throw new Error("futures contract returned no delivery dates"); this.deliveryDateCache = deliveryDates[0]; // Eagerly cache margin inputs so `estimateOrderMargin` can be synchronous. @@ -88,13 +90,17 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { encodeCreate(intent: OrderIntent): `0x${string}` { if (this.deliveryDateCache === null) { - throw new Error("futures: getContext() must be called before encodeCreate()"); + throw new Error( + "futures: getContext() must be called before encodeCreate()", + ); } const qty = Number(intent.size); if (qty <= 0 || qty > 127) { throw new Error(`futures: order size ${qty} must be in (0, 127]`); } - const signed = (intent.side === "buy" ? qty : -qty) as number & { readonly __int8__: true }; + const signed = (intent.side === "buy" ? qty : -qty) as number & { + readonly __int8__: true; + }; return encodeFunctionData({ abi: FuturesAbi, functionName: "createOrder", @@ -126,10 +132,17 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { if (this.deliveryDurationDaysCache === null) return 0n; // marginPct is loaded lazily at first canPlace call; if we don't have it // yet, return 0 and let the engine gate sort it out on the first tx. - const cachedMarginPct = (this.venue as unknown as { marginPercentCache?: bigint }) - .marginPercentCache; + const cachedMarginPct = ( + this.venue as unknown as { marginPercentCache?: bigint } + ).marginPercentCache; if (!cachedMarginPct) return 0n; - return (intent.price * this.deliveryDurationDaysCache * intent.size * cachedMarginPct) / 100n; + return ( + (intent.price * + this.deliveryDurationDaysCache * + intent.size * + cachedMarginPct) / + 100n + ); } async estimateCreateGas(account: `0x${string}`): Promise { @@ -139,7 +152,12 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: FuturesAbi, functionName: "createOrder", - args: [1_000_000n, this.deliveryDateCache, "", 1 as number & { readonly __int8__: true }], + args: [ + 1_000_000n, + this.deliveryDateCache, + "", + 1 as number & { readonly __int8__: true }, + ], account, }); } catch { @@ -171,8 +189,10 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { class FuturesBook implements BookSource { readonly matchingMode: MatchingMode = "exact"; private readonly inst: FuturesInstrumentAdapter; - constructor(inst: FuturesInstrumentAdapter) { + private readonly multicallBatchSize: number; + constructor(inst: FuturesInstrumentAdapter, multicallBatchSize: number) { this.inst = inst; + this.multicallBatchSize = multicallBatchSize; } async tick(): Promise { @@ -183,21 +203,34 @@ class FuturesBook implements BookSource { const v = this.inst.venue; const dd = this.inst.getDeliveryDate(); if (dd === null) { - throw new Error("futures: getContext() must be called before book.snapshot()"); + throw new Error( + "futures: getContext() must be called before book.snapshot()", + ); } const depth = BigInt(opts.depth ?? 200); const [bidPrices, askPrices] = await v.publicClient.multicall({ allowFailure: false, contracts: [ - { address: v.address, abi: FuturesAbi, functionName: "getBidPrices", args: [dd, depth] }, - { address: v.address, abi: FuturesAbi, functionName: "getAskPrices", args: [dd, depth] }, + { + address: v.address, + abi: FuturesAbi, + functionName: "getBidPrices", + args: [dd, depth], + }, + { + address: v.address, + abi: FuturesAbi, + functionName: "getAskPrices", + args: [dd, depth], + }, ], }); - if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; + if (bidPrices.length === 0 && askPrices.length === 0) + return { bids: [], asks: [] }; - const calls = [ + const allCalls = [ ...bidPrices.map((p) => ({ address: v.address, abi: FuturesAbi, @@ -211,16 +244,34 @@ class FuturesBook implements BookSource { args: [dd, p, false] as const, })), ]; - const results = await v.publicClient.multicall({ allowFailure: false, contracts: calls }); - const bidsRaw: DepthLevel[] = bidPrices.map((p, i) => ({ price: p, quantity: results[i] })); + // Chunk to stay under RPC payload / timeout limits. + const batchSize = this.multicallBatchSize; + const allResults: bigint[] = []; + for (let i = 0; i < allCalls.length; i += batchSize) { + const chunk = allCalls.slice(i, i + batchSize); + const chunkResults = await v.publicClient.multicall({ + allowFailure: false, + contracts: chunk, + }); + allResults.push(...chunkResults); + } + + const bidsRaw: DepthLevel[] = bidPrices.map((p, i) => ({ + price: p, + quantity: allResults[i], + })); const asksRaw: DepthLevel[] = askPrices.map((p, i) => ({ price: p, - quantity: results[bidPrices.length + i], + quantity: allResults[bidPrices.length + i], })); // EnumerableSet returns prices in unspecified order; sort for the consumer. - const bids = bidsRaw.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); - const asks = asksRaw.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); + const bids = bidsRaw.sort((a, b) => + a.price < b.price ? 1 : a.price > b.price ? -1 : 0, + ); + const asks = asksRaw.sort((a, b) => + a.price < b.price ? -1 : a.price > b.price ? 1 : 0, + ); return { bids, asks }; } } diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 722a931..5a8ea6b 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -33,10 +33,16 @@ export class FuturesOwnOrders implements OwnOrderSource { private readonly venue: FuturesVenueAdapter; private readonly logger: pino.Logger; + private readonly multicallBatchSize: number; - constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { + constructor( + venue: FuturesVenueAdapter, + logger: pino.Logger, + multicallBatchSize: number, + ) { this.venue = venue; this.logger = logger.child({ component: "futures-own-orders" }); + this.multicallBatchSize = multicallBatchSize; } async list(): Promise { @@ -75,19 +81,31 @@ export class FuturesOwnOrders implements OwnOrderSource { return; } - const calls = orderIds.map((id) => ({ + const allCalls = orderIds.map((id) => ({ address: this.venue.address, abi: FuturesAbi, functionName: "getOrderById" as const, args: [id] as const, })); - const orders = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: calls, - }); + + // Chunk to stay under RPC payload / timeout limits. + const batchSize = this.multicallBatchSize; + const allOrders: unknown[] = []; + for (let i = 0; i < allCalls.length; i += batchSize) { + const chunk = allCalls.slice(i, i + batchSize); + const chunkResults = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: chunk, + }); + allOrders.push(...chunkResults); + } for (let i = 0; i < orderIds.length; i++) { - const o = orders[i]; + const o = allOrders[i] as { + participant: string; + pricePerDay: bigint; + isBuy: boolean; + }; if ( !o.participant || o.participant === "0x0000000000000000000000000000000000000000" diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 4c9885b..0e1ab5d 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -25,6 +25,8 @@ export interface FuturesVenueOptions { wallet: WalletContext; address: `0x${string}`; multicall3Address?: `0x${string}`; + /** Max calls per Multicall3 read batch. Default 100. */ + multicallBatchSize?: number; logger: pino.Logger; } @@ -54,6 +56,7 @@ export class FuturesVenueAdapter implements VenueAdapter { private readonly logger: pino.Logger; private readonly multicall3Address: `0x${string}`; + readonly multicallBatchSize: number; private instrumentSingleton: FuturesInstrumentAdapter | null = null; private vaultAddressCache: `0x${string}` | null = null; @@ -74,8 +77,10 @@ export class FuturesVenueAdapter implements VenueAdapter { const mc3 = opts.multicall3Address ?? (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); - if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); + if (!mc3) + throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; + this.multicallBatchSize = opts.multicallBatchSize ?? 100; this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); @@ -90,8 +95,16 @@ export class FuturesVenueAdapter implements VenueAdapter { const [oracle, divisor] = await this.publicClient.multicall({ allowFailure: false, contracts: [ - { address: this.address, abi: FuturesAbi, functionName: "hashrateOracle" }, - { address: this.address, abi: FuturesAbi, functionName: "hashpriceScalingDivisor" }, + { + address: this.address, + abi: FuturesAbi, + functionName: "hashrateOracle", + }, + { + address: this.address, + abi: FuturesAbi, + functionName: "hashpriceScalingDivisor", + }, ], }); return { oracle, divisor }; @@ -101,7 +114,10 @@ export class FuturesVenueAdapter implements VenueAdapter { async getInstrument(): Promise { if (!this.instrumentSingleton) { - this.instrumentSingleton = new FuturesInstrumentAdapter(this, this.logger); + this.instrumentSingleton = new FuturesInstrumentAdapter( + this, + this.logger, + ); } return this.instrumentSingleton; } @@ -143,7 +159,11 @@ export class FuturesVenueAdapter implements VenueAdapter { engine: `0x${string}`; token: `0x${string}`; }> { - if (this.vaultAddressCache && this.engineAddressCache && this.collateralTokenCache) { + if ( + this.vaultAddressCache && + this.engineAddressCache && + this.collateralTokenCache + ) { return { vault: this.vaultAddressCache, engine: this.engineAddressCache, @@ -153,8 +173,16 @@ export class FuturesVenueAdapter implements VenueAdapter { const [vault, engine] = await this.publicClient.multicall({ allowFailure: false, contracts: [ - { address: this.address, abi: FuturesAbi, functionName: "collateralVault" }, - { address: this.address, abi: FuturesAbi, functionName: "marginEngine" }, + { + address: this.address, + abi: FuturesAbi, + functionName: "collateralVault", + }, + { + address: this.address, + abi: FuturesAbi, + functionName: "marginEngine", + }, ], }); const token = await this.publicClient.readContract({ @@ -189,8 +217,14 @@ export class FuturesVenueAdapter implements VenueAdapter { * static-ish (admin-changeable) so we read them once and reuse for the * `estimateOrderMargin` formula. */ - async getMarginInputs(): Promise<{ deliveryDurationDays: bigint; marginPct: bigint }> { - if (this.deliveryDurationDaysCache !== null && this.marginPercentCache !== null) { + async getMarginInputs(): Promise<{ + deliveryDurationDays: bigint; + marginPct: bigint; + }> { + if ( + this.deliveryDurationDaysCache !== null && + this.marginPercentCache !== null + ) { return { deliveryDurationDays: this.deliveryDurationDaysCache, marginPct: this.marginPercentCache, @@ -199,8 +233,16 @@ export class FuturesVenueAdapter implements VenueAdapter { const [duration, liqMarginPct] = await this.publicClient.multicall({ allowFailure: false, contracts: [ - { address: this.address, abi: FuturesAbi, functionName: "deliveryDurationDays" }, - { address: this.address, abi: FuturesAbi, functionName: "liquidationMarginPercent" }, + { + address: this.address, + abi: FuturesAbi, + functionName: "deliveryDurationDays", + }, + { + address: this.address, + abi: FuturesAbi, + functionName: "liquidationMarginPercent", + }, ], }); // Note: `getMarginPercent` on chain adds a breach-penalty term we don't @@ -244,7 +286,12 @@ class FuturesCollateralAccount implements CollateralAccount { ] = await this.venue.publicClient.multicall({ allowFailure: false, contracts: [ - { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { + address: vault, + abi: CollateralVaultAbi, + functionName: "balanceOf", + args: [owner], + }, { address: engine, abi: PortfolioMarginEngineAbi, @@ -269,8 +316,18 @@ class FuturesCollateralAccount implements CollateralAccount { functionName: "getFuturesUnrealizedPnl", args: [owner], }, - { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, - { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + { + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }, + { + address: mc3, + abi: Multicall3Abi, + functionName: "getEthBalance", + args: [owner], + }, ], }); diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index b3bbcad..0f6213d 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -40,12 +40,18 @@ const futuresVenueSchema = Type.Object( kind: Type.Literal("futures", { description: "Venue type — must be 'futures' for the Futures contract.", }), - address: TypeEthAddress({ description: "Deployed Futures contract address." }), + address: TypeEthAddress({ + description: "Deployed Futures contract address.", + }), wallet: Type.String({ - description: "Key in the top-level `wallets` map identifying the signer for this venue.", + description: + "Key in the top-level `wallets` map identifying the signer for this venue.", }), }, - { ...Closed, description: "Futures venue identification and signer selection." }, + { + ...Closed, + description: "Futures venue identification and signer selection.", + }, ); const futuresPricingSchema = Type.Object( @@ -56,7 +62,8 @@ const futuresPricingSchema = Type.Object( }), riskAversion: Type.Number({ minimum: 0, - description: "Avellaneda–Stoikov risk aversion γ. Higher = stronger inventory skew.", + description: + "Avellaneda–Stoikov risk aversion γ. Higher = stronger inventory skew.", }), marginCallTimeSec: Type.Number({ minimum: 0, @@ -65,16 +72,19 @@ const futuresPricingSchema = Type.Object( }), minSpreadBps: Type.Number({ minimum: 0, - description: "Floor on the half-spread in bps. Quotes never tighten below this.", + description: + "Floor on the half-spread in bps. Quotes never tighten below this.", }), volatilityMultiplier: Type.Number({ minimum: 0, - description: "Multiplier applied to realized volatility when widening the spread.", + description: + "Multiplier applied to realized volatility when widening the spread.", }), maxSkewTicks: Type.Number({ const: 0, default: 0, - description: "Pinned to 0 — under reservation-price the skew is encoded in r itself.", + description: + "Pinned to 0 — under reservation-price the skew is encoded in r itself.", }), }, { ...Closed, description: "Reservation-price pricing parameters." }, @@ -103,7 +113,8 @@ const futuresSizingSchema = Type.Object( taperRatio: Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1, - description: "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous.", + description: + "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous.", }), }, { ...Closed, description: "Geometric-taper sizing parameters." }, @@ -113,11 +124,13 @@ export const futuresRootSchema = Type.Object( { nodeEnv: Type.String({ default: "development", - description: "Environment label (development/staging/production). Used for log enrichment only.", + description: + "Environment label (development/staging/production). Used for log enrichment only.", }), commitHash: Type.String({ default: "unknown", - description: "Build-time commit SHA; surfaced via /healthz for ops correlation.", + description: + "Build-time commit SHA; surfaced via /health for ops correlation.", }), logLevel: Type.String({ default: "info", @@ -125,7 +138,8 @@ export const futuresRootSchema = Type.Object( }), dryRun: Type.Boolean({ default: false, - description: "If true, all order writes are skipped — quotes are computed but not submitted.", + description: + "If true, all order writes are skipped — quotes are computed but not submitted.", }), cancelOrdersOnShutdown: Type.Boolean({ default: true, @@ -133,7 +147,8 @@ export const futuresRootSchema = Type.Object( "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", }), wallets: Type.Record(Type.String(), walletSchema, { - description: "Map of named signer wallets; venue.wallet selects which one signs.", + description: + "Map of named signer wallets; venue.wallet selects which one signs.", }), network: networkSchema, venue: futuresVenueSchema, @@ -145,6 +160,14 @@ export const futuresRootSchema = Type.Object( oracle: oracleSchema, timing: timingSchema, health: healthSchema, + multicallBatchSize: Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum number of contract calls bundled into a single Multicall3 read. " + + "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + + "at the cost of more round-trips.", + }), }, { ...Closed, description: "Titan Market Maker — Futures app config." }, ); @@ -160,10 +183,14 @@ export type FuturesMakerConfig = Omit< timing: ParsedTimingConfig; collateral: ParsedCollateralConfig; oracle: ParsedOracleConfig; - sizing: Omit & { baseQuantity: bigint }; + sizing: Omit & { + baseQuantity: bigint; + }; }; -export function loadFuturesConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): FuturesMakerConfig { +export function loadFuturesConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): FuturesMakerConfig { return loadConfigFromFile({ schema: futuresRootSchema, path: opts.path, @@ -176,12 +203,17 @@ export function loadFuturesConfig(opts: { path?: string; env?: NodeJS.ProcessEnv oracle: parseOracleConfig(raw.oracle), sizing: { ...raw.sizing, - baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), + baseQuantity: configBigint( + String(raw.sizing.baseQuantity), + "sizing.baseQuantity", + ), }, }), validate: (cfg) => { if (!cfg.wallets[cfg.venue.wallet]) { - throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); + throw new ConfigError( + `venue.wallet "${cfg.venue.wallet}" not in wallets map`, + ); } }, }); diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index 0b0d252..dcb28fa 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -21,17 +21,31 @@ import { loadFuturesConfig } from "./config.ts"; async function main(): Promise { loadDotenvFiles(import.meta.dirname); const config = loadFuturesConfig(); - const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); - logger.info({ venue: "futures", address: config.venue.address, dryRun: config.dryRun }, "starting futures mm"); + const logger = pino({ + level: config.logLevel, + serializers: { err: serializeError }, + }); + logger.info( + { venue: "futures", address: config.venue.address, dryRun: config.dryRun }, + "starting futures mm", + ); - const network = createNetworkClients(config.network.name, config.network.rpcUrl); - const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const network = createNetworkClients( + config.network.name, + config.network.rpcUrl, + ); + const wallets = new WalletRegistry( + config.wallets, + network.chain, + network.transport, + ); const wallet = wallets.get(config.venue.wallet); const venue = await createFuturesVenue({ network, wallet, address: config.venue.address, + multicallBatchSize: config.multicallBatchSize, logger, }); const instrument = await venue.getInstrument(); @@ -42,7 +56,10 @@ async function main(): Promise { await instrument.ownOrders.bootstrap(); const history = config.oracle.history - ? new HashpriceOracleSubgraphSource({ url: config.oracle.history.subgraphUrl, logger }) + ? new HashpriceOracleSubgraphSource({ + url: config.oracle.history.subgraphUrl, + logger, + }) : undefined; const oracle = new OracleTracker(instrument, logger, { windowSize: config.oracle.windowSize, @@ -54,7 +71,10 @@ async function main(): Promise { const gas = new GasTracker( network.publicClient, { - ethPriceFeedAddress: config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + ethPriceFeedAddress: + config.network.ethPriceFeed === "" + ? undefined + : config.network.ethPriceFeed, gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, gasCapMultiplier: config.gas.gasCapMultiplier, }, diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index 4cecbd4..d83e957 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -51,7 +51,8 @@ export class HealthCheck { lastTickAt = 0; executorStats: ExecutorStats | null = null; walletAddress = ""; - status: "initializing" | "init-error" | "running" | "error" | "stopped" = "initializing"; + status: "initializing" | "init-error" | "running" | "error" | "stopped" = + "initializing"; lastError: ErrorInfo | null = null; paused = false; @@ -69,9 +70,12 @@ export class HealthCheck { this.startedAt = Date.now(); this.server = createServer((req, res) => { try { - if (req.method === "POST" && req.url === "/stop") return this.handleStop(res); - if (req.method === "POST" && req.url === "/start") return this.handleStart(res); - if (req.method === "GET" && req.url === "/health") return this.handleHealth(res); + if (req.method === "POST" && req.url === "/stop") + return this.handleStop(res); + if (req.method === "POST" && req.url === "/start") + return this.handleStart(res); + if (req.method === "GET" && req.url === "/health") + return this.handleHealth(res); res.writeHead(404); res.end(); } catch (err) { @@ -84,7 +88,10 @@ export class HealthCheck { const logger = this.opts.logger; const port = this.opts.port; this.server.listen(port, () => { - logger.info({ url: `http://localhost:${port}/health` }, "health endpoint started"); + logger.info( + { url: `http://localhost:${port}/health` }, + "health endpoint started", + ); resolve(); }); }); @@ -222,28 +229,60 @@ function bigIntReplacer(_key: string, value: unknown): unknown { interface OwnOrdersView { count: number; - bids: Array<{ orderId: `0x${string}`; price: bigint; size: bigint }>; - asks: Array<{ orderId: `0x${string}`; price: bigint; size: bigint }>; + bids: Array<{ price: bigint; quantity: bigint; orderIds: `0x${string}`[] }>; + asks: Array<{ price: bigint; quantity: bigint; orderIds: `0x${string}`[] }>; } /** - * Snapshot of resting MM orders, split by side and sorted top-of-book first - * (best bid = highest price, best ask = lowest price). Bigints are stringified - * by `bigIntReplacer` when the payload is serialised. + * Snapshot of resting MM orders, aggregated by (price, side) so that multiple + * orders at the same price level are collapsed into one entry with the + * individual `orderIds` listed as a nested array. + * + * Sorted top-of-book first (best bid = highest price, best ask = lowest price). + * Bigints are stringified by `bigIntReplacer` when the payload is serialised. */ -function serializeOwnOrders(orders: ReadonlyMap<`0x${string}`, OwnOrder>): OwnOrdersView { - const bids: OwnOrder[] = []; - const asks: OwnOrder[] = []; +function serializeOwnOrders( + orders: ReadonlyMap<`0x${string}`, OwnOrder>, +): OwnOrdersView { + // Aggregate by price within each side. + const bidMap = new Map< + bigint, + { quantity: bigint; orderIds: `0x${string}`[] } + >(); + const askMap = new Map< + bigint, + { quantity: bigint; orderIds: `0x${string}`[] } + >(); for (const order of orders.values()) { - (order.side === "buy" ? bids : asks).push(order); + const map = order.side === "buy" ? bidMap : askMap; + const entry = map.get(order.price); + if (entry) { + entry.quantity += order.size; + entry.orderIds.push(order.orderId); + } else { + map.set(order.price, { quantity: order.size, orderIds: [order.orderId] }); + } } - // bigint compare; Number(a-b) would lose precision on large prices. - bids.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); - asks.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); - const project = (o: OwnOrder) => ({ orderId: o.orderId, price: o.price, size: o.size }); + + const sortDesc = (a: [bigint, unknown], b: [bigint, unknown]) => + a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0; + const sortAsc = (a: [bigint, unknown], b: [bigint, unknown]) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; + + const bidEntries = [...bidMap.entries()].sort(sortDesc); + const askEntries = [...askMap.entries()].sort(sortAsc); + return { count: orders.size, - bids: bids.map(project), - asks: asks.map(project), + bids: bidEntries.map(([price, v]) => ({ + price, + quantity: v.quantity, + orderIds: v.orderIds, + })), + asks: askEntries.map(([price, v]) => ({ + price, + quantity: v.quantity, + orderIds: v.orderIds, + })), }; } From be120668c00a4aa96175ba80c1fae22253ed3be2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 25 May 2026 18:25:17 +0500 Subject: [PATCH 027/180] MulticallBatchSize and scripts chores - Add `multicallBatchSize` default to all futures YAML configs - Fix health endpoint reference in schema description - Reorganize script order and simplify local/dev/stg/prd commands --- market-maker/configs/futures.dev.yml | 2 ++ market-maker/configs/futures.local.yml | 2 ++ market-maker/configs/futures.prd.yml | 4 +++- market-maker/configs/futures.stg.yml | 4 +++- market-maker/package.json | 22 ++++++++++++---------- market-maker/schemas/futures.json | 17 ++++++++++++++++- market-maker/src/apps/futures/config.ts | 18 ++++++++++-------- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 936c043..aa69ce1 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -87,3 +87,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 84f4095..6580048 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -74,3 +74,5 @@ oracle: health: port: 3001 + +multicallBatchSize: 10 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index c38d61f..329090f 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -39,7 +39,7 @@ pricing: sizing: strategy: geometric-taper - baseQuantity: "500000000" # venue-native (contracts base units) + baseQuantity: "500000000" # venue-native (contracts base units) numLevelsPerSide: 4 taperRatio: 0.6 @@ -80,3 +80,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 0f9e47c..c4ac433 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -39,7 +39,7 @@ pricing: sizing: strategy: geometric-taper - baseQuantity: "100000000" # venue-native (contracts base units) + baseQuantity: "100000000" # venue-native (contracts base units) numLevelsPerSide: 4 taperRatio: 0.6 @@ -80,3 +80,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/package.json b/market-maker/package.json index affca39..0c74965 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -8,18 +8,20 @@ }, "scripts": { "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "gen:schemas": "pnpm node scripts/gen-schemas.ts", + "pretypecheck": "pnpm gen:schemas", "typecheck": "tsgo --noEmit", "node": "node --import=amaro/strip --conditions=typescript", - "local:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", - "local:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", - "dev:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", - "dev:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", - "stg:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.stg.yml", - "stg:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.stg.yml", - "prd:perps": "pnpm node --watch src/apps/perps/main.ts --config configs/perps.prd.yml", - "prd:futures": "pnpm node --watch src/apps/futures/main.ts --config configs/futures.prd.yml", - "gen:schemas": "pnpm node scripts/gen-schemas.ts", - "pretypecheck": "pnpm node scripts/gen-schemas.ts" + "perps": "pnpm node --watch src/apps/perps/main.ts", + "futures": "pnpm node --watch src/apps/futures/main.ts", + "local:perps": "pnpm perps --config configs/perps.local.yml | pino-pretty", + "local:futures": "pnpm futures --config configs/futures.local.yml | pino-pretty", + "dev:perps": "pnpm perps --config configs/perps.dev.yml | pino-pretty", + "dev:futures": "pnpm futures --config configs/futures.dev.yml | pino-pretty", + "stg:perps": "pnpm perps --config configs/perps.stg.yml", + "stg:futures": "pnpm futures --config configs/futures.stg.yml", + "prd:perps": "pnpm perps --config configs/perps.prd.yml", + "prd:futures": "pnpm futures --config configs/futures.prd.yml" }, "dependencies": { "@sinclair/typebox": "^0.34.49", diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index f7ee9eb..b7c9cdd 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -30,7 +30,7 @@ }, "commitHash": { "default": "unknown", - "description": "Build-time commit SHA; surfaced via /healthz for ops correlation.", + "description": "Build-time commit SHA; surfaced via /health for ops correlation.", "type": "string" }, "logLevel": { @@ -905,6 +905,21 @@ "default": 3001 } } + }, + "multicallBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum number of contract calls bundled into a single Multicall3 read. Calls are chunked transparently; lower values reduce RPC timeouts on busy providers at the cost of more round-trips.", + "default": 10 } } } diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 0f6213d..6e3178c 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -160,14 +160,16 @@ export const futuresRootSchema = Type.Object( oracle: oracleSchema, timing: timingSchema, health: healthSchema, - multicallBatchSize: Type.Number({ - minimum: 1, - default: 10, - description: - "Maximum number of contract calls bundled into a single Multicall3 read. " + - "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + - "at the cost of more round-trips.", - }), + multicallBatchSize: Type.Optional( + Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum number of contract calls bundled into a single Multicall3 read. " + + "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + + "at the cost of more round-trips.", + }), + ), }, { ...Closed, description: "Titan Market Maker — Futures app config." }, ); From 942c81948938a9bfcb361320b8a3d6af70d583ac Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 12:12:49 +0500 Subject: [PATCH 028/180] refactor(market-maker): move order execution lifecycle into instrument adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `executeOrders(intent)` to InstrumentAdapter — the adapter owns the full lifecycle: encoding, batching, tx chunking, nonce sequencing, broadcast, and receipt gathering. The executor no longer leaks multicall details — it just says what to do and gets back what happened. - Futures adapter: individual closeOrder per cancel (max 20/batch), batched createOrders per create (max 10/call), WRITE_BATCH_SIZE=50 calls/tx. - Perps adapter: individual cancelOrder/createOrder per order, WRITE_BATCH_SIZE=30 calls/tx. - Simplify OrderExecutor: ~80 lines of call-building + tx chunking replaced with a single `instrument.executeOrders()` call + gas recording loop. - Remove `multicallBatchSize` from OrderExecutorConfig (now internal to adapters). Remove `cancelBatchSize`/`createBatchSize`/encodeBatch members from InstrumentAdapter interface — they were implementation details. - Add `multicallBatchSize` config field to perps app (was missing entirely). - Add `getLogger()` to PerpsVenueAdapter for adapter-side logging. --- market-maker/configs/futures.dev.yml | 2 +- market-maker/configs/perps.dev.yml | 2 + market-maker/configs/perps.local.yml | 6 +- market-maker/configs/perps.prd.yml | 10 +- market-maker/configs/perps.stg.yml | 4 +- market-maker/schemas/perps.json | 59 +++----- .../src/adapters/futures/instrument.ts | 139 ++++++++++++++++++ market-maker/src/adapters/perps/instrument.ts | 96 ++++++++++++ market-maker/src/adapters/perps/venue.ts | 131 +++++++++++++---- market-maker/src/apps/perps/config.ts | 69 ++++++--- market-maker/src/apps/perps/main.ts | 31 +++- market-maker/src/core/adapter.ts | 33 +++++ market-maker/src/core/orderExecutor.ts | 133 ++++++++--------- market-maker/src/core/runner.ts | 36 ++++- market-maker/tests/core/oracleTracker.test.ts | 10 +- 15 files changed, 594 insertions(+), 167 deletions(-) diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index aa69ce1..aa79bac 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -88,4 +88,4 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +multicallBatchSize: 3 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 12ccdc4..3e28826 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -90,3 +90,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3002} + +multicallBatchSize: 10 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index a1ae1df..a1a1f0c 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -33,14 +33,14 @@ venue: pricing: strategy: effective-spread - minSpreadBps: 20 # wider in dev so test fills are obvious + minSpreadBps: 20 # wider in dev so test fills are obvious volatilityMultiplier: 2.0 inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: strategy: linear - baseQuantity: "100000" # venue-native units (hashrate base) + baseQuantity: "100000" # venue-native units (hashrate base) numLevelsPerSide: 3 risk: @@ -80,3 +80,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index f219beb..b6ca001 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -42,14 +42,14 @@ pricing: sizing: strategy: linear - baseQuantity: "10000000" # venue-native units (hashrate base) + baseQuantity: "10000000" # venue-native units (hashrate base) numLevelsPerSide: 5 risk: maxPositionSize: 1000 - maxUtilizationPct: 75 # tighter than dev/stg - minCollateralBalance: 100 # operational floor - maxDailyLossUsd: 1000 # daily loss circuit-breaker + maxUtilizationPct: 75 # tighter than dev/stg + minCollateralBalance: 100 # operational floor + maxDailyLossUsd: 1000 # daily loss circuit-breaker maxGasBudgetPerHourUsd: 50 maxGasBudgetPerDayUsd: 500 gasSpikeThresholdPct: 200 @@ -82,3 +82,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index f658acc..637b106 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -41,7 +41,7 @@ pricing: sizing: strategy: linear - baseQuantity: "1000000" # venue-native units (hashrate base) + baseQuantity: "1000000" # venue-native units (hashrate base) numLevelsPerSide: 5 risk: @@ -81,3 +81,5 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} + +multicallBatchSize: 10 diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 78e8f4d..0f86238 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -74,9 +74,7 @@ "additionalProperties": false, "description": "Named signer wallet. Referenced by venue.wallet.", "type": "object", - "required": [ - "privateKey" - ], + "required": ["privateKey"], "properties": { "privateKey": { "anyOf": [ @@ -100,10 +98,7 @@ "additionalProperties": false, "description": "Network connection settings.", "type": "object", - "required": [ - "name", - "rpcUrl" - ], + "required": ["name", "rpcUrl"], "properties": { "name": { "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", @@ -141,11 +136,7 @@ "additionalProperties": false, "description": "Perps venue identification and signer selection.", "type": "object", - "required": [ - "kind", - "address", - "wallet" - ], + "required": ["kind", "address", "wallet"], "properties": { "kind": { "description": "Venue type — must be 'perps' for HashPowerPerpsDEX.", @@ -251,11 +242,7 @@ "additionalProperties": false, "description": "Linear-ladder sizing parameters.", "type": "object", - "required": [ - "strategy", - "baseQuantity", - "numLevelsPerSide" - ], + "required": ["strategy", "baseQuantity", "numLevelsPerSide"], "properties": { "strategy": { "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", @@ -540,9 +527,7 @@ "additionalProperties": false, "description": "Gas-pricing knobs.", "type": "object", - "required": [ - "gasCapMultiplier" - ], + "required": ["gasCapMultiplier"], "properties": { "gasCapMultiplier": { "anyOf": [ @@ -564,10 +549,7 @@ "additionalProperties": false, "description": "Collateral vault behaviour.", "type": "object", - "required": [ - "autoDeposit", - "autoDepositMinAmount" - ], + "required": ["autoDeposit", "autoDepositMinAmount"], "properties": { "autoDeposit": { "anyOf": [ @@ -650,11 +632,7 @@ "additionalProperties": false, "description": "OracleTracker / volatility-window configuration.", "type": "object", - "required": [ - "windowSize", - "precisionBits", - "historyLookbackMultiplier" - ], + "required": ["windowSize", "precisionBits", "historyLookbackMultiplier"], "properties": { "windowSize": { "anyOf": [ @@ -706,9 +684,7 @@ "additionalProperties": false, "description": "Historical price source for σ window backfill.", "type": "object", - "required": [ - "subgraphUrl" - ], + "required": ["subgraphUrl"], "properties": { "subgraphUrl": { "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", @@ -862,9 +838,7 @@ "additionalProperties": false, "description": "Health-check HTTP server.", "type": "object", - "required": [ - "port" - ], + "required": ["port"], "properties": { "port": { "anyOf": [ @@ -882,6 +856,21 @@ "default": 3001 } } + }, + "multicallBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum number of contract calls bundled into a single multicall write tx. Calls are chunked transparently; lower values reduce per-tx gas cost at the cost of more txs.", + "default": 10 } } } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 7b5d166..97a034e 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -4,6 +4,8 @@ import type { BookSource, CancelIntent, DepthLevel, + ExecuteOrdersIntent, + ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, MatchingMode, @@ -17,6 +19,13 @@ import { FuturesOwnOrders } from "./ownOrders.ts"; const FUTURES_INSTRUMENT_ID = "futures"; +/** Maximum closeOrder calls per cancellation batch. */ +const CANCEL_BATCH_SIZE = 20; +/** Maximum orders per createOrders call. */ +const CREATE_BATCH_SIZE = 10; +/** Maximum encoded calls per multicall write tx (conservative for Base 30M gas limit). */ +const WRITE_BATCH_SIZE = 50; + export class FuturesInstrumentAdapter implements InstrumentAdapter { readonly id = FUTURES_INSTRUMENT_ID; readonly venue: FuturesVenueAdapter; @@ -116,6 +125,136 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { }); } + /** + * Execute cancels then creates on-chain. Owns the full lifecycle: + * encoding → batching → tx chunking → broadcast → receipt gathering. + * + * Cancels (individual closeOrder calls, max 20 per batch) are placed + * before creates (createOrders calls, max 10 per batch) so margin is + * freed before new risk is added. + */ + async executeOrders( + intent: ExecuteOrdersIntent, + ): Promise { + return this.executeOrdersImpl(intent, this.venue.getLogger()); + } + + // ── Private implementation ────────────────────────────────────────── + + /** + * Shared implementation — the inner `logger` param makes this testable + * without coupling to the full venue adapter. + */ + private async executeOrdersImpl( + intent: ExecuteOrdersIntent, + logger: pino.Logger, + ): Promise { + // 1. Build the ordered call list: cancels first, then creates. + const calls = this.buildCallList(intent); + if (calls.length === 0) { + return { receipts: [], errors: [] }; + } + + if (intent.dryRun) { + const totalBatches = Math.ceil(calls.length / WRITE_BATCH_SIZE); + logger.info( + { + cancels: intent.cancels.length, + creates: intent.creates.length, + calls: calls.length, + batches: totalBatches, + }, + "DRY RUN: would send multicall batches", + ); + return { receipts: [], errors: [] }; + } + + // 2. Chunk into tx-sized groups and broadcast sequentially. + const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; + const errors: Error[] = []; + const totalBatches = Math.ceil(calls.length / WRITE_BATCH_SIZE); + + for (let offset = 0; offset < calls.length; offset += WRITE_BATCH_SIZE) { + const chunk = calls.slice(offset, offset + WRITE_BATCH_SIZE); + const batchNum = Math.floor(offset / WRITE_BATCH_SIZE) + 1; + + try { + const hash = await this.venue.multicall(chunk, { + maxFeePerGas: intent.maxFeePerGas, + }); + const receipt = await this.venue.publicClient.waitForTransactionReceipt( + { hash }, + ); + receipts.push({ + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + }); + logger.info( + { + calls: chunk.length, + batch: `${batchNum}/${totalBatches}`, + gas: receipt.gasUsed.toString(), + }, + "futures multicall chunk executed", + ); + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + errors.push(wrapped); + logger.error( + { + err: wrapped, + calls: chunk.length, + batch: `${batchNum}/${totalBatches}`, + }, + "futures multicall chunk failed — continuing with next chunk", + ); + } + } + + return { receipts, errors }; + } + + /** Build the ordered call list: cancels (individual closeOrder) then creates (createOrders). */ + private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[] { + const calls: `0x${string}`[] = []; + + // Cancels: chunk by CANCEL_BATCH_SIZE, each = one closeOrder call. + for (let i = 0; i < intent.cancels.length; i += CANCEL_BATCH_SIZE) { + const batch = intent.cancels.slice(i, i + CANCEL_BATCH_SIZE); + for (const c of batch) { + calls.push(this.encodeCancel(c)); + } + } + + // Creates: chunk by CREATE_BATCH_SIZE, each chunk = one createOrders call. + for (let i = 0; i < intent.creates.length; i += CREATE_BATCH_SIZE) { + const batch = intent.creates.slice(i, i + CREATE_BATCH_SIZE); + calls.push(this.encodeCreateOrders(batch)); + } + + return calls; + } + + /** Encode a batch of creates via the `createOrders` contract function. */ + private encodeCreateOrders(intents: OrderIntent[]): `0x${string}` { + if (!this.deliveryDateCache) { + throw new Error("Delivery data cache not filled"); + } + const _deliveryDateCache = this.deliveryDateCache; + return encodeFunctionData({ + abi: FuturesAbi, + functionName: "createOrders", + args: [ + intents.map((i) => ({ + pricePerDay: i.price, + deliveryDate: _deliveryDateCache, + destURL: "", + qty: i.side === "buy" ? Number(i.size) : -Number(i.size), + })), + ], + }); + } + /** * Mirrors `Futures.getMaintenanceMarginForPosition` for a single new order: * IM_added = pricePerDay × deliveryDurationDays × |qty| × marginPct / 100 diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 669d71f..5b38ffc 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -1,8 +1,11 @@ import { encodeFunctionData } from "viem"; +import type pino from "pino"; import type { BookSource, CancelIntent, DepthLevel, + ExecuteOrdersIntent, + ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, MatchingMode, @@ -81,6 +84,99 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { }); } + /** + * Execute cancels then creates on-chain. Perps uses individual + * cancelOrder / createOrder calls (no batch functions on the contract). + */ + async executeOrders( + intent: ExecuteOrdersIntent, + ): Promise { + return this.executeOrdersImpl(intent, this.venue.getLogger()); + } + + // ── Private implementation ────────────────────────────────────────── + + /** Max encoded calls per multicall write tx (conservative for Base 30M gas limit). */ + private static readonly WRITE_BATCH_SIZE = 30; + + private async executeOrdersImpl( + intent: ExecuteOrdersIntent, + logger: pino.Logger, + ): Promise { + // 1. Build the ordered call list: cancels first, then creates. + const calls: `0x${string}`[] = []; + for (const c of intent.cancels) { + calls.push(this.encodeCancel(c)); + } + for (const c of intent.creates) { + calls.push(this.encodeCreate(c)); + } + + if (calls.length === 0) { + return { receipts: [], errors: [] }; + } + + // 2. Chunk into tx-sized groups and broadcast sequentially. + const max = PerpsInstrumentAdapter.WRITE_BATCH_SIZE; + + if (intent.dryRun) { + const totalBatches = Math.ceil(calls.length / max); + logger.info( + { + cancels: intent.cancels.length, + creates: intent.creates.length, + calls: calls.length, + batches: totalBatches, + }, + "DRY RUN: would send multicall batches", + ); + return { receipts: [], errors: [] }; + } + + const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; + const errors: Error[] = []; + const totalBatches = Math.ceil(calls.length / max); + + for (let offset = 0; offset < calls.length; offset += max) { + const chunk = calls.slice(offset, offset + max); + const batchNum = Math.floor(offset / max) + 1; + + try { + const hash = await this.venue.multicall(chunk, { + maxFeePerGas: intent.maxFeePerGas, + }); + const receipt = await this.venue.publicClient.waitForTransactionReceipt( + { hash }, + ); + receipts.push({ + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + }); + logger.info( + { + calls: chunk.length, + batch: `${batchNum}/${totalBatches}`, + gas: receipt.gasUsed.toString(), + }, + "perps multicall chunk executed", + ); + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + errors.push(wrapped); + logger.error( + { + err: wrapped, + calls: chunk.length, + batch: `${batchNum}/${totalBatches}`, + }, + "perps multicall chunk failed — continuing with next chunk", + ); + } + } + + return { receipts, errors }; + } + /** * Mirrors `HashPowerPerpsDEX._getMargin` for a single new resting order: * IM_added = imSpotShock × notional / 1e18 diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index dfef667..0da6d71 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -15,7 +15,10 @@ import { CollateralVaultAbi } from "collateral-margin-contracts/abi/CollateralVa import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; -import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; +import { + RawOracleReader, + chainlinkAggregatorAbi, +} from "../../core/rawOracle.ts"; import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { PerpsInstrumentAdapter } from "./instrument.ts"; import { PerpsVenueEvents } from "./events.ts"; @@ -68,8 +71,11 @@ export class PerpsVenueAdapter implements VenueAdapter { this.address = opts.address; this.logger = opts.logger.child({ component: "perps-venue" }); - const mc3 = opts.multicall3Address ?? (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); - if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); + const mc3 = + opts.multicall3Address ?? + (this.chain.contracts?.multicall3?.address as `0x${string}` | undefined); + if (!mc3) + throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; this.events = new PerpsVenueEvents(this.publicClient, this.address); @@ -88,19 +94,27 @@ export class PerpsVenueAdapter implements VenueAdapter { abi: HashPowerPerpsDEXAbi, functionName: "priceOracle", }); - const [oracleDecimals, tokenDecimals] = await this.publicClient.multicall({ - allowFailure: false, - contracts: [ - { address: oracle, abi: chainlinkAggregatorAbi, functionName: "decimals" }, - { address: token, abi: erc20Abi, functionName: "decimals" }, - ], - }); + const [oracleDecimals, tokenDecimals] = + await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { + address: oracle, + abi: chainlinkAggregatorAbi, + functionName: "decimals", + }, + { address: token, abi: erc20Abi, functionName: "decimals" }, + ], + }); if (tokenDecimals > oracleDecimals) { throw new Error( `perps: tokenDecimals (${tokenDecimals}) > oracleDecimals (${oracleDecimals})`, ); } - return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals) }; + return { + oracle, + divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + }; }, }); } @@ -112,7 +126,10 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.instrumentSingleton; } - async multicall(calls: `0x${string}`[], opts: { maxFeePerGas?: bigint } = {}): Promise<`0x${string}`> { + async multicall( + calls: `0x${string}`[], + opts: { maxFeePerGas?: bigint } = {}, + ): Promise<`0x${string}`> { try { return await this.wallet.walletClient.writeContract({ address: this.address, @@ -141,8 +158,16 @@ export class PerpsVenueAdapter implements VenueAdapter { // ── Internal helpers used by the collateral account & instrument ───────── - async resolveAddresses(): Promise<{ vault: `0x${string}`; engine: `0x${string}`; token: `0x${string}` }> { - if (this.vaultAddressCache && this.engineAddressCache && this.collateralTokenCache) { + async resolveAddresses(): Promise<{ + vault: `0x${string}`; + engine: `0x${string}`; + token: `0x${string}`; + }> { + if ( + this.vaultAddressCache && + this.engineAddressCache && + this.collateralTokenCache + ) { return { vault: this.vaultAddressCache, engine: this.engineAddressCache, @@ -152,9 +177,21 @@ export class PerpsVenueAdapter implements VenueAdapter { const [vault, engine, token] = await this.publicClient.multicall({ allowFailure: false, contracts: [ - { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "vault" }, - { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "portfolioMargin" }, - { address: this.address, abi: HashPowerPerpsDEXAbi, functionName: "collateralToken" }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "vault", + }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "portfolioMargin", + }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "collateralToken", + }, ], }); this.vaultAddressCache = vault; @@ -167,6 +204,10 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.multicall3Address; } + getLogger(): pino.Logger { + return this.logger; + } + /** * Latest price oracle answer rebased to token decimals (no tick rounding). * See `RawOracleReader` for rationale. @@ -221,14 +262,54 @@ class PerpsCollateralAccount implements CollateralAccount { ] = await this.venue.publicClient.multicall({ allowFailure: false, contracts: [ - { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, - { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, - { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, - { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getOrderMargin", args: [owner] }, - { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, - { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, - { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, - { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + { + address: vault, + abi: CollateralVaultAbi, + functionName: "balanceOf", + args: [owner], + }, + { + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioIM", + args: [owner], + }, + { + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioMM", + args: [owner], + }, + { + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getOrderMargin", + args: [owner], + }, + { + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getUnrealizedPnl", + args: [owner], + }, + { + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getPendingFunding", + args: [owner], + }, + { + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }, + { + address: mc3, + abi: Multicall3Abi, + functionName: "getEthBalance", + args: [owner], + }, ], }); diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index a94e7fd..4c65311 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -37,27 +37,38 @@ const Closed = { additionalProperties: false }; const perpsVenueSchema = Type.Object( { - kind: Type.Literal("perps", { description: "Venue type — must be 'perps' for HashPowerPerpsDEX." }), - address: TypeEthAddress({ description: "Deployed HashPowerPerpsDEX contract address." }), + kind: Type.Literal("perps", { + description: "Venue type — must be 'perps' for HashPowerPerpsDEX.", + }), + address: TypeEthAddress({ + description: "Deployed HashPowerPerpsDEX contract address.", + }), wallet: Type.String({ - description: "Key in the top-level `wallets` map identifying the signer for this venue.", + description: + "Key in the top-level `wallets` map identifying the signer for this venue.", }), }, - { ...Closed, description: "Perps venue identification and signer selection." }, + { + ...Closed, + description: "Perps venue identification and signer selection.", + }, ); const perpsPricingSchema = Type.Object( { strategy: Type.Literal("effective-spread", { - description: "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", + description: + "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", }), minSpreadBps: Type.Number({ minimum: 0, - description: "Floor on the half-spread in bps. Quotes never tighten below this.", + description: + "Floor on the half-spread in bps. Quotes never tighten below this.", }), volatilityMultiplier: Type.Number({ minimum: 0, - description: "Multiplier applied to realized volatility when widening the spread.", + description: + "Multiplier applied to realized volatility when widening the spread.", }), inventorySkewGamma: Type.Number({ minimum: 0, @@ -66,7 +77,8 @@ const perpsPricingSchema = Type.Object( }), maxSkewTicks: Type.Number({ minimum: 0, - description: "Cap on absolute ticks a level can be skewed from the symmetric mid.", + description: + "Cap on absolute ticks a level can be skewed from the symmetric mid.", }), }, { ...Closed, description: "Effective-spread pricing parameters." }, @@ -78,7 +90,8 @@ const perpsPricingSchema = Type.Object( const perpsSizingSchema = Type.Object( { strategy: Type.Literal("linear", { - description: "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", + description: + "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", }), baseQuantity: Type.Union( [Type.String({ pattern: "^\\d+$" }), Type.Number()], @@ -99,11 +112,13 @@ export const perpsRootSchema = Type.Object( { nodeEnv: Type.String({ default: "development", - description: "Environment label (development/staging/production). Used for log enrichment only.", + description: + "Environment label (development/staging/production). Used for log enrichment only.", }), commitHash: Type.String({ default: "unknown", - description: "Build-time commit SHA; surfaced via /healthz for ops correlation.", + description: + "Build-time commit SHA; surfaced via /healthz for ops correlation.", }), logLevel: Type.String({ default: "info", @@ -111,7 +126,8 @@ export const perpsRootSchema = Type.Object( }), dryRun: Type.Boolean({ default: false, - description: "If true, all order writes are skipped — quotes are computed but not submitted.", + description: + "If true, all order writes are skipped — quotes are computed but not submitted.", }), cancelOrdersOnShutdown: Type.Boolean({ default: true, @@ -119,7 +135,8 @@ export const perpsRootSchema = Type.Object( "If true (default), SIGINT/SIGTERM trigger executor.cancelAll() before exit. Set false to leave resting orders on the book on exit (useful for restarts).", }), wallets: Type.Record(Type.String(), walletSchema, { - description: "Map of named signer wallets; venue.wallet selects which one signs.", + description: + "Map of named signer wallets; venue.wallet selects which one signs.", }), network: networkSchema, venue: perpsVenueSchema, @@ -131,6 +148,15 @@ export const perpsRootSchema = Type.Object( oracle: oracleSchema, timing: timingSchema, health: healthSchema, + multicallBatchSize: Type.Optional( + Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum number of contract calls bundled into a single multicall write tx. " + + "Calls are chunked transparently; lower values reduce per-tx gas cost at the cost of more txs.", + }), + ), }, { ...Closed, description: "Titan Market Maker — Perps app config." }, ); @@ -146,10 +172,14 @@ export type PerpsMakerConfig = Omit< timing: ParsedTimingConfig; collateral: ParsedCollateralConfig; oracle: ParsedOracleConfig; - sizing: Omit & { baseQuantity: bigint }; + sizing: Omit & { + baseQuantity: bigint; + }; }; -export function loadPerpsConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } = {}): PerpsMakerConfig { +export function loadPerpsConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): PerpsMakerConfig { return loadConfigFromFile({ schema: perpsRootSchema, path: opts.path, @@ -162,12 +192,17 @@ export function loadPerpsConfig(opts: { path?: string; env?: NodeJS.ProcessEnv } oracle: parseOracleConfig(raw.oracle), sizing: { ...raw.sizing, - baseQuantity: configBigint(String(raw.sizing.baseQuantity), "sizing.baseQuantity"), + baseQuantity: configBigint( + String(raw.sizing.baseQuantity), + "sizing.baseQuantity", + ), }, }), validate: (cfg) => { if (!cfg.wallets[cfg.venue.wallet]) { - throw new ConfigError(`venue.wallet "${cfg.venue.wallet}" not in wallets map`); + throw new ConfigError( + `venue.wallet "${cfg.venue.wallet}" not in wallets map`, + ); } }, }); diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index e39bd24..c72e933 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -21,11 +21,24 @@ import { loadPerpsConfig } from "./config.ts"; async function main(): Promise { loadDotenvFiles(import.meta.dirname); const config = loadPerpsConfig(); - const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); - logger.info({ venue: "perps", address: config.venue.address, dryRun: config.dryRun }, "starting perps mm"); + const logger = pino({ + level: config.logLevel, + serializers: { err: serializeError }, + }); + logger.info( + { venue: "perps", address: config.venue.address, dryRun: config.dryRun }, + "starting perps mm", + ); - const network = createNetworkClients(config.network.name, config.network.rpcUrl); - const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const network = createNetworkClients( + config.network.name, + config.network.rpcUrl, + ); + const wallets = new WalletRegistry( + config.wallets, + network.chain, + network.transport, + ); const wallet = wallets.get(config.venue.wallet); const venue = await createPerpsVenue({ @@ -37,7 +50,10 @@ async function main(): Promise { const instrument = await venue.getInstrument(); const history = config.oracle.history - ? new HashpriceOracleSubgraphSource({ url: config.oracle.history.subgraphUrl, logger }) + ? new HashpriceOracleSubgraphSource({ + url: config.oracle.history.subgraphUrl, + logger, + }) : undefined; const oracle = new OracleTracker(instrument, logger, { windowSize: config.oracle.windowSize, @@ -49,7 +65,10 @@ async function main(): Promise { const gas = new GasTracker( network.publicClient, { - ethPriceFeedAddress: config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + ethPriceFeedAddress: + config.network.ethPriceFeed === "" + ? undefined + : config.network.ethPriceFeed, gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, gasCapMultiplier: config.gas.gasCapMultiplier, }, diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index cf30dc6..a412769 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -30,6 +30,27 @@ export interface CancelIntent { orderId: `0x${string}`; } +/** + * Batch of cancellations and creations the adapter should execute on-chain. + * Cancels always process before creates within each tx. + */ +export interface ExecuteOrdersIntent { + cancels: CancelIntent[]; + creates: OrderIntent[]; + /** Gas price cap. If not set, the wallet estimates from the network. */ + maxFeePerGas?: bigint; + /** If true, log what would be done but don't broadcast txs. */ + dryRun?: boolean; +} + +/** Result from {@link InstrumentAdapter.executeOrders}. */ +export interface ExecuteOrdersResult { + /** Receipts from successful tx chunks (for gas tracking). */ + receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[]; + /** Non-fatal errors from failed tx chunks. */ + errors: Error[]; +} + // ─── Resting state types ──────────────────────────────────────────────────── /** An order resting on the venue owned by the MM. */ @@ -251,6 +272,18 @@ export interface InstrumentAdapter { encodeCreate(intent: OrderIntent): `0x${string}`; encodeCancel(intent: CancelIntent): `0x${string}`; + /** + * Execute a batch of order cancellations and creations on-chain. + * + * The adapter owns the full lifecycle: encoding, batching, tx chunking, + * nonce sequencing, and gas optimisation. The caller receives receipts + * for gas tracking and any non-fatal errors from failed tx chunks. + * + * Cancels are always processed before creates within each tx to free + * margin before adding new risk. + */ + executeOrders(intent: ExecuteOrdersIntent): Promise; + /** * Estimate the additional Initial Margin a new order would add to the * wallet's portfolio IM. Used by RiskManager to call diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index f5626fd..74eba89 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -1,5 +1,10 @@ import type pino from "pino"; -import type { InstrumentAdapter, OrderIntent, OwnOrder, Side } from "./adapter.ts"; +import type { + InstrumentAdapter, + OrderIntent, + OwnOrder, + Side, +} from "./adapter.ts"; import type { Quoter } from "./quoter.ts"; import type { BookTracker } from "./bookTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; @@ -59,7 +64,10 @@ export class OrderExecutor { this.gas = gas; this.risk = risk; this.oracle = oracle; - this.logger = logger.child({ component: "executor", instrument: instrument.id }); + this.logger = logger.child({ + component: "executor", + instrument: instrument.id, + }); } async reconcile(desired: OrderIntent[]): Promise { @@ -94,7 +102,10 @@ export class OrderExecutor { // Pre-trade engine gate: ask whether the new orders' total IM still fits // the wallet's portfolio IM budget. If not, only cancel; don't add risk. - const placeAllowed = await this.risk.canPlaceOrders(ordersToPlace, this.instrument); + const placeAllowed = await this.risk.canPlaceOrders( + ordersToPlace, + this.instrument, + ); const places = placeAllowed ? ordersToPlace : []; if (!placeAllowed) { this.logger.warn( @@ -107,47 +118,25 @@ export class OrderExecutor { return; } - const calls: `0x${string}`[] = []; - for (const order of ordersToCancel) { - calls.push(this.instrument.encodeCancel({ orderId: order.orderId })); - } - for (const intent of places) { - calls.push(this.instrument.encodeCreate(intent)); + // Delegate full lifecycle to the adapter: encoding, batching, tx chunking, + // nonce sequencing, gas optimisation. The executor no longer leaks multicall + // details — it just says what to do and gets back what happened. + const result = await this.instrument.executeOrders({ + cancels: ordersToCancel.map((o) => ({ orderId: o.orderId })), + creates: places, + maxFeePerGas: this.gas.cappedGasPrice(), + dryRun: this.cfg.dryRun, + }); + + // Record gas cost from successful tx chunks. + for (const receipt of result.receipts) { + this.risk.recordGasCost(this.computeTxGasCost(receipt)); } - if (this.cfg.dryRun) { - this.logger.info( - { cancels: ordersToCancel.length, places: places.length }, - "DRY RUN: would send multicall batch", - ); - return; - } - - const maxFeePerGas = this.gas.cappedGasPrice(); - try { - const hash = await this.instrument.venue.multicall(calls, { maxFeePerGas }); - const receipt = await this.instrument.venue.publicClient.waitForTransactionReceipt({ hash }); - const gasCost = this.computeTxGasCost(receipt); - this.risk.recordGasCost(gasCost); - - this.stats.ordersCancelled += ordersToCancel.length; - this.stats.ordersPlaced += places.length; - - this.logger.info( - { - cancels: ordersToCancel.length, - places: places.length, - gas: receipt.gasUsed.toString(), - }, - "multicall batch executed", - ); - } catch (err) { - this.logger.error( - { cancels: ordersToCancel.length, places: places.length, err }, - "multicall batch failed", - ); - throw err; - } + // Stats count intended orders; partial failure undercounts but metrics + // remain directionally correct (next reconciliation retries the remainder). + this.stats.ordersCancelled += ordersToCancel.length; + this.stats.ordersPlaced += places.length; this.lastRequoteAt = Date.now(); this.lastQuoteMidPrice = this.oracle.currentPrice; @@ -159,32 +148,25 @@ export class OrderExecutor { if (orders.length === 0) return; this.logger.warn({ count: orders.length }, "cancelling all orders"); - const calls = orders.map((o) => this.instrument.encodeCancel({ orderId: o.orderId })); - if (this.cfg.dryRun) { - this.logger.info({ count: orders.length }, "DRY RUN: would cancel all orders"); - return; - } + const result = await this.instrument.executeOrders({ + cancels: orders.map((o) => ({ orderId: o.orderId })), + creates: [], + maxFeePerGas: this.gas.cappedGasPrice(), + dryRun: this.cfg.dryRun, + }); - const maxFeePerGas = this.gas.cappedGasPrice(); - try { - const hash = await this.instrument.venue.multicall(calls, { maxFeePerGas }); - const receipt = await this.instrument.venue.publicClient.waitForTransactionReceipt({ hash }); - const gasCost = this.computeTxGasCost(receipt); - this.risk.recordGasCost(gasCost); - this.stats.ordersCancelled += orders.length; - this.logger.info( - { count: orders.length, gas: receipt.gasUsed.toString() }, - "all orders cancelled", - ); - } catch (err) { - this.logger.error({ count: orders.length, err }, "cancel-all multicall failed"); - throw err; + // Record gas cost from successful tx chunks. + for (const receipt of result.receipts) { + this.risk.recordGasCost(this.computeTxGasCost(receipt)); } + + this.stats.ordersCancelled += orders.length; } private shouldRequote(desired: OrderIntent[]): boolean { - if (Date.now() - this.lastRequoteAt < this.effectiveCooldownMs()) return false; + if (Date.now() - this.lastRequoteAt < this.effectiveCooldownMs()) + return false; const expectedCount = desired.length; if (this.book.ownOrders.size < expectedCount) return true; @@ -202,11 +184,15 @@ export class OrderExecutor { } private effectiveCooldownMs(): number { - return this.risk.throttled ? this.cfg.requoteCooldownMs * 3 : this.cfg.requoteCooldownMs; + return this.risk.throttled + ? this.cfg.requoteCooldownMs * 3 + : this.cfg.requoteCooldownMs; } private effectiveRequoteThreshold(): number { - return this.risk.throttled ? this.cfg.requoteThresholdTicks * 2 : this.cfg.requoteThresholdTicks; + return this.risk.throttled + ? this.cfg.requoteThresholdTicks * 2 + : this.cfg.requoteThresholdTicks; } /** @@ -240,9 +226,11 @@ export class OrderExecutor { let worstDesiredAsk: bigint | undefined; for (const i of desired) { if (i.side === "buy") { - if (worstDesiredBid === undefined || i.price < worstDesiredBid) worstDesiredBid = i.price; + if (worstDesiredBid === undefined || i.price < worstDesiredBid) + worstDesiredBid = i.price; } else { - if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) worstDesiredAsk = i.price; + if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) + worstDesiredAsk = i.price; } } const stale: OwnOrder[] = []; @@ -297,9 +285,18 @@ export class OrderExecutor { return m; } - private computeTxGasCost(receipt: { gasUsed: bigint; effectiveGasPrice: bigint }): bigint { + /** + * Compute USD-denominated gas cost from a receipt. + */ + private computeTxGasCost(receipt: { + gasUsed: bigint; + effectiveGasPrice: bigint; + }): bigint { if (this.gas.ethPriceUsd === 0n) return 0n; - return (receipt.gasUsed * receipt.effectiveGasPrice * this.gas.ethPriceUsd) / 10n ** 18n; + return ( + (receipt.gasUsed * receipt.effectiveGasPrice * this.gas.ethPriceUsd) / + 10n ** 18n + ); } } diff --git a/market-maker/src/core/runner.ts b/market-maker/src/core/runner.ts index d3dc4c7..5d0b450 100644 --- a/market-maker/src/core/runner.ts +++ b/market-maker/src/core/runner.ts @@ -54,7 +54,20 @@ export interface RunnerOpts { * successful tick. */ export async function runMakerLoop(opts: RunnerOpts): Promise { - const { pollIntervalMs, instrument, oracle, gas, book, inventory, collateral, risk, quoter, executor, health, logger } = opts; + const { + pollIntervalMs, + instrument, + oracle, + gas, + book, + inventory, + collateral, + risk, + quoter, + executor, + health, + logger, + } = opts; const cancelOrdersOnShutdown = opts.cancelOrdersOnShutdown ?? true; const mmAddress = instrument.venue.wallet.account.address; @@ -93,8 +106,14 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { } catch (err) { health.status = "init-error"; health.lastError = toErrorInfo(err); - const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); - logger.warn({ err, attempt, retryInMs: delay }, "initialization failed, retrying"); + const delay = Math.min( + BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), + MAX_ERROR_DELAY_MS, + ); + logger.warn( + { err, attempt, retryInMs: delay }, + "initialization failed, retrying", + ); await sleep(delay); } } @@ -113,7 +132,9 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { logger.error({ err }, "failed to cancel orders during shutdown"); } } else { - logger.info("cancelOrdersOnShutdown=false; leaving resting orders on the book"); + logger.info( + "cancelOrdersOnShutdown=false; leaving resting orders on the book", + ); } book.stop(); await health.stop(); @@ -153,7 +174,7 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { vaultBalance: collateral.vaultBalance.toString(), orders: book.ownOrders.size, }, - "tick", + "main loop tick", ); const ok = risk.check(); @@ -185,7 +206,10 @@ export async function runMakerLoop(opts: RunnerOpts): Promise { health.lastTickAt = Date.now(); const delay = consecutiveErrors > 0 - ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) + ? Math.min( + BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, + MAX_ERROR_DELAY_MS, + ) : pollIntervalMs; await sleep(delay); } diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index bbbf481..550e7a5 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -10,7 +10,9 @@ import type { const noop = () => {}; function makeLogger(): never { - return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; + return { + child: () => ({ debug: noop, info: noop, warn: noop, error: noop }), + } as never; } function makeInstrument(prices: bigint[]): InstrumentAdapter { @@ -33,6 +35,7 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { getContext: async () => ({}), encodeCreate: () => "0x", encodeCancel: () => "0x", + executeOrders: async () => ({ receipts: [], errors: [] }), estimateOrderMargin: () => 0n, estimateCreateGas: async () => 0n, }; @@ -69,7 +72,10 @@ describe("OracleTracker", () => { }); it("updates price from instrument", async () => { - const tracker = new OracleTracker(makeInstrument([100_000_000n]), makeLogger()); + const tracker = new OracleTracker( + makeInstrument([100_000_000n]), + makeLogger(), + ); await tracker.update(); assert.equal(tracker.currentPrice, 100_000_000n); }); From 43a87d8ff1affdbb2dee5f87ce0bcc52dd1c37d2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 12:30:46 +0500 Subject: [PATCH 029/180] refactor(market-maker): split batch sizing into read/cancel/create config keys - Rename `multicallBatchSize` -> `readBatchSize` (Multicall3 read chunking, used by FuturesBook.snapshot and FuturesOwnOrders.bootstrap). - Add `cancelBatchSize` (max cancels per write batch) and `createBatchSize` (max creates per write batch) as first-class config fields. The adapters now read these from `venue.{cancel,create}BatchSize` instead of hardcoded constants. - Wire all three through the full stack: yaml -> config schema -> venue options -> venue adapter -> instrument adapter internals. - Add `readBatchSize` to perps venue (was missing entirely). - Defaults: futures read=10 cancel=20 create=10; perps read=10 cancel=30 create=30. --- market-maker/configs/futures.dev.yml | 5 +- market-maker/configs/futures.local.yml | 5 +- market-maker/configs/futures.prd.yml | 5 +- market-maker/configs/futures.stg.yml | 5 +- market-maker/configs/perps.dev.yml | 5 +- market-maker/configs/perps.local.yml | 5 +- market-maker/configs/perps.prd.yml | 5 +- market-maker/configs/perps.stg.yml | 5 +- market-maker/schemas/futures.json | 37 ++++++++- market-maker/schemas/perps.json | 83 ++++++++++++++++--- market-maker/src/adapters/futures/index.ts | 7 +- .../src/adapters/futures/instrument.ts | 30 ++++--- .../src/adapters/futures/ownOrders.ts | 8 +- market-maker/src/adapters/futures/venue.ts | 14 +++- market-maker/src/adapters/perps/index.ts | 10 ++- market-maker/src/adapters/perps/instrument.ts | 28 +++++-- market-maker/src/adapters/perps/venue.ts | 12 +++ market-maker/src/apps/futures/config.ts | 32 ++++--- market-maker/src/apps/futures/main.ts | 4 +- market-maker/src/apps/perps/config.ts | 32 +++++-- market-maker/src/apps/perps/main.ts | 3 + 21 files changed, 265 insertions(+), 75 deletions(-) diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index aa79bac..48d23e8 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -88,4 +88,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 3 +readBatchSize: 3 +# Per-operation batch sizes for writes (futures: closeOrder limit / createOrders limit). +cancelBatchSize: 20 +createBatchSize: 10 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 6580048..6d1d90e 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -75,4 +75,7 @@ oracle: health: port: 3001 -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 20 +createBatchSize: 10 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index 329090f..7278fb2 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -81,4 +81,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 20 +createBatchSize: 10 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index c4ac433..281b474 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -81,4 +81,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 20 +createBatchSize: 10 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 3e28826..1b6cd06 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -91,4 +91,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3002} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes (perps: individual cancelOrder / createOrder). +cancelBatchSize: 30 +createBatchSize: 30 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index a1a1f0c..614f074 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -81,4 +81,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 30 +createBatchSize: 30 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index b6ca001..087132b 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -83,4 +83,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 30 +createBatchSize: 30 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index 637b106..483a726 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -82,4 +82,7 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -multicallBatchSize: 10 +readBatchSize: 10 +# Per-operation batch sizes for writes. +cancelBatchSize: 30 +createBatchSize: 30 diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index b7c9cdd..aa9c03b 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -20,7 +20,10 @@ "collateral", "oracle", "timing", - "health" + "health", + "readBatchSize", + "cancelBatchSize", + "createBatchSize" ], "properties": { "nodeEnv": { @@ -906,7 +909,7 @@ } } }, - "multicallBatchSize": { + "readBatchSize": { "anyOf": [ { "minimum": 1, @@ -920,6 +923,36 @@ ], "description": "Maximum number of contract calls bundled into a single Multicall3 read. Calls are chunked transparently; lower values reduce RPC timeouts on busy providers at the cost of more round-trips.", "default": 10 + }, + "cancelBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum closeOrder calls per cancellation batch. The adapter groups cancels into chunks of this size before sending.", + "default": 20 + }, + "createBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum orders per createOrders call. The adapter packs creates into batches of this size.", + "default": 10 } } } diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 0f86238..9fc3ef9 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -20,7 +20,10 @@ "collateral", "oracle", "timing", - "health" + "health", + "readBatchSize", + "cancelBatchSize", + "createBatchSize" ], "properties": { "nodeEnv": { @@ -74,7 +77,9 @@ "additionalProperties": false, "description": "Named signer wallet. Referenced by venue.wallet.", "type": "object", - "required": ["privateKey"], + "required": [ + "privateKey" + ], "properties": { "privateKey": { "anyOf": [ @@ -98,7 +103,10 @@ "additionalProperties": false, "description": "Network connection settings.", "type": "object", - "required": ["name", "rpcUrl"], + "required": [ + "name", + "rpcUrl" + ], "properties": { "name": { "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", @@ -136,7 +144,11 @@ "additionalProperties": false, "description": "Perps venue identification and signer selection.", "type": "object", - "required": ["kind", "address", "wallet"], + "required": [ + "kind", + "address", + "wallet" + ], "properties": { "kind": { "description": "Venue type — must be 'perps' for HashPowerPerpsDEX.", @@ -242,7 +254,11 @@ "additionalProperties": false, "description": "Linear-ladder sizing parameters.", "type": "object", - "required": ["strategy", "baseQuantity", "numLevelsPerSide"], + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide" + ], "properties": { "strategy": { "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", @@ -527,7 +543,9 @@ "additionalProperties": false, "description": "Gas-pricing knobs.", "type": "object", - "required": ["gasCapMultiplier"], + "required": [ + "gasCapMultiplier" + ], "properties": { "gasCapMultiplier": { "anyOf": [ @@ -549,7 +567,10 @@ "additionalProperties": false, "description": "Collateral vault behaviour.", "type": "object", - "required": ["autoDeposit", "autoDepositMinAmount"], + "required": [ + "autoDeposit", + "autoDepositMinAmount" + ], "properties": { "autoDeposit": { "anyOf": [ @@ -632,7 +653,11 @@ "additionalProperties": false, "description": "OracleTracker / volatility-window configuration.", "type": "object", - "required": ["windowSize", "precisionBits", "historyLookbackMultiplier"], + "required": [ + "windowSize", + "precisionBits", + "historyLookbackMultiplier" + ], "properties": { "windowSize": { "anyOf": [ @@ -684,7 +709,9 @@ "additionalProperties": false, "description": "Historical price source for σ window backfill.", "type": "object", - "required": ["subgraphUrl"], + "required": [ + "subgraphUrl" + ], "properties": { "subgraphUrl": { "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", @@ -838,7 +865,9 @@ "additionalProperties": false, "description": "Health-check HTTP server.", "type": "object", - "required": ["port"], + "required": [ + "port" + ], "properties": { "port": { "anyOf": [ @@ -857,7 +886,7 @@ } } }, - "multicallBatchSize": { + "readBatchSize": { "anyOf": [ { "minimum": 1, @@ -869,8 +898,38 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Maximum number of contract calls bundled into a single multicall write tx. Calls are chunked transparently; lower values reduce per-tx gas cost at the cost of more txs.", + "description": "Maximum number of contract calls bundled into a single Multicall3 read. Calls are chunked transparently; lower values reduce RPC timeouts.", "default": 10 + }, + "cancelBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum cancelOrder calls per cancellation batch. Perps has no batch cancel — each cancel is one call.", + "default": 30 + }, + "createBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Maximum createOrder calls per creation batch. Perps has no batch create — each create is one call.", + "default": 30 } } } diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index ed45dc1..fe6a272 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -8,7 +8,12 @@ export interface CreateFuturesVenueOpts { wallet: WalletContext; address: `0x${string}`; multicall3Address?: `0x${string}`; - multicallBatchSize?: number; + /** Max calls per Multicall3 read batch. Default 100. */ + readBatchSize: number; + /** Max closeOrder calls per cancellation batch. Default 20. */ + cancelBatchSize: number; + /** Max orders per createOrders call. Default 10. */ + createBatchSize: number; logger: pino.Logger; } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 97a034e..919e7e8 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -19,11 +19,7 @@ import { FuturesOwnOrders } from "./ownOrders.ts"; const FUTURES_INSTRUMENT_ID = "futures"; -/** Maximum closeOrder calls per cancellation batch. */ -const CANCEL_BATCH_SIZE = 20; -/** Maximum orders per createOrders call. */ -const CREATE_BATCH_SIZE = 10; -/** Maximum encoded calls per multicall write tx (conservative for Base 30M gas limit). */ +/** Maximum encoded calls per multicall write tx (safety net for block gas limit). */ const WRITE_BATCH_SIZE = 50; export class FuturesInstrumentAdapter implements InstrumentAdapter { @@ -38,7 +34,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { this.venue = venue; - const batchSize = venue.multicallBatchSize; + const batchSize = venue.readBatchSize; this.book = new FuturesBook(this, batchSize); this.ownOrders = new FuturesOwnOrders(venue, logger, batchSize); } @@ -216,19 +212,21 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { /** Build the ordered call list: cancels (individual closeOrder) then creates (createOrders). */ private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[] { + const cancelSize = this.venue.cancelBatchSize; + const createSize = this.venue.createBatchSize; const calls: `0x${string}`[] = []; - // Cancels: chunk by CANCEL_BATCH_SIZE, each = one closeOrder call. - for (let i = 0; i < intent.cancels.length; i += CANCEL_BATCH_SIZE) { - const batch = intent.cancels.slice(i, i + CANCEL_BATCH_SIZE); + // Cancels: chunk by cancelBatchSize, each = one closeOrder call. + for (let i = 0; i < intent.cancels.length; i += cancelSize) { + const batch = intent.cancels.slice(i, i + cancelSize); for (const c of batch) { calls.push(this.encodeCancel(c)); } } - // Creates: chunk by CREATE_BATCH_SIZE, each chunk = one createOrders call. - for (let i = 0; i < intent.creates.length; i += CREATE_BATCH_SIZE) { - const batch = intent.creates.slice(i, i + CREATE_BATCH_SIZE); + // Creates: chunk by createBatchSize, each chunk = one createOrders call. + for (let i = 0; i < intent.creates.length; i += createSize) { + const batch = intent.creates.slice(i, i + createSize); calls.push(this.encodeCreateOrders(batch)); } @@ -328,10 +326,10 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { class FuturesBook implements BookSource { readonly matchingMode: MatchingMode = "exact"; private readonly inst: FuturesInstrumentAdapter; - private readonly multicallBatchSize: number; - constructor(inst: FuturesInstrumentAdapter, multicallBatchSize: number) { + private readonly readBatchSize: number; + constructor(inst: FuturesInstrumentAdapter, readBatchSize: number) { this.inst = inst; - this.multicallBatchSize = multicallBatchSize; + this.readBatchSize = readBatchSize; } async tick(): Promise { @@ -385,7 +383,7 @@ class FuturesBook implements BookSource { ]; // Chunk to stay under RPC payload / timeout limits. - const batchSize = this.multicallBatchSize; + const batchSize = this.readBatchSize; const allResults: bigint[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { const chunk = allCalls.slice(i, i + batchSize); diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 5a8ea6b..387034d 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -33,16 +33,16 @@ export class FuturesOwnOrders implements OwnOrderSource { private readonly venue: FuturesVenueAdapter; private readonly logger: pino.Logger; - private readonly multicallBatchSize: number; + private readonly readBatchSize: number; constructor( venue: FuturesVenueAdapter, logger: pino.Logger, - multicallBatchSize: number, + readBatchSize: number, ) { this.venue = venue; this.logger = logger.child({ component: "futures-own-orders" }); - this.multicallBatchSize = multicallBatchSize; + this.readBatchSize = readBatchSize; } async list(): Promise { @@ -89,7 +89,7 @@ export class FuturesOwnOrders implements OwnOrderSource { })); // Chunk to stay under RPC payload / timeout limits. - const batchSize = this.multicallBatchSize; + const batchSize = this.readBatchSize; const allOrders: unknown[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { const chunk = allCalls.slice(i, i + batchSize); diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 0e1ab5d..02022f8 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -26,7 +26,11 @@ export interface FuturesVenueOptions { address: `0x${string}`; multicall3Address?: `0x${string}`; /** Max calls per Multicall3 read batch. Default 100. */ - multicallBatchSize?: number; + readBatchSize: number; + /** Max closeOrder calls per cancellation batch. Default 20. */ + cancelBatchSize: number; + /** Max orders per createOrders call. Default 10. */ + createBatchSize: number; logger: pino.Logger; } @@ -56,7 +60,9 @@ export class FuturesVenueAdapter implements VenueAdapter { private readonly logger: pino.Logger; private readonly multicall3Address: `0x${string}`; - readonly multicallBatchSize: number; + readonly readBatchSize: number; + readonly cancelBatchSize: number; + readonly createBatchSize: number; private instrumentSingleton: FuturesInstrumentAdapter | null = null; private vaultAddressCache: `0x${string}` | null = null; @@ -80,7 +86,9 @@ export class FuturesVenueAdapter implements VenueAdapter { if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; - this.multicallBatchSize = opts.multicallBatchSize ?? 100; + this.readBatchSize = opts.readBatchSize; + this.cancelBatchSize = opts.cancelBatchSize; + this.createBatchSize = opts.createBatchSize; this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts index 3dc5962..055dd06 100644 --- a/market-maker/src/adapters/perps/index.ts +++ b/market-maker/src/adapters/perps/index.ts @@ -9,6 +9,12 @@ export interface CreatePerpsVenueOpts { address: `0x${string}`; /** Optional Multicall3 override; defaults to chain.contracts.multicall3.address. */ multicall3Address?: `0x${string}`; + /** Max calls per Multicall3 read batch. Default 100. */ + readBatchSize: number; + /** Max cancelOrder calls per batch. Default 30. */ + cancelBatchSize: number; + /** Max createOrder calls per batch. Default 30. */ + createBatchSize: number; logger: pino.Logger; } @@ -20,7 +26,9 @@ export interface CreatePerpsVenueOpts { * * matchingMode = "limit" — orders fill at any price better-or-equal. */ -export async function createPerpsVenue(opts: CreatePerpsVenueOpts): Promise { +export async function createPerpsVenue( + opts: CreatePerpsVenueOpts, +): Promise { return new PerpsVenueAdapter(opts); } diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 5b38ffc..18720c4 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -96,20 +96,29 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { // ── Private implementation ────────────────────────────────────────── - /** Max encoded calls per multicall write tx (conservative for Base 30M gas limit). */ - private static readonly WRITE_BATCH_SIZE = 30; - + /** + * Shared implementation — the inner `logger` param makes this testable + * without coupling to the full venue adapter. + */ private async executeOrdersImpl( intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { // 1. Build the ordered call list: cancels first, then creates. + const cancelSize = this.venue.cancelBatchSize; + const createSize = this.venue.createBatchSize; const calls: `0x${string}`[] = []; - for (const c of intent.cancels) { - calls.push(this.encodeCancel(c)); + + // Cancels: chunk by cancelBatchSize. + for (let i = 0; i < intent.cancels.length; i += cancelSize) { + const batch = intent.cancels.slice(i, i + cancelSize); + for (const c of batch) calls.push(this.encodeCancel(c)); } - for (const c of intent.creates) { - calls.push(this.encodeCreate(c)); + + // Creates: chunk by createBatchSize. + for (let i = 0; i < intent.creates.length; i += createSize) { + const batch = intent.creates.slice(i, i + createSize); + for (const c of batch) calls.push(this.encodeCreate(c)); } if (calls.length === 0) { @@ -117,7 +126,10 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } // 2. Chunk into tx-sized groups and broadcast sequentially. - const max = PerpsInstrumentAdapter.WRITE_BATCH_SIZE; + // Perps has no batch contract functions — each cancel/create is one call. + // The cancelBatchSize / createBatchSize already control grouping, so we + // use a generous tx-level safety limit. + const max = 200; if (intent.dryRun) { const totalBatches = Math.ceil(calls.length / max); diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 0da6d71..0caae25 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -28,6 +28,12 @@ export interface PerpsVenueOptions { wallet: WalletContext; address: `0x${string}`; multicall3Address?: `0x${string}`; + /** Max calls per Multicall3 read batch. Default 100. */ + readBatchSize: number; + /** Max cancelOrder calls per batch. Default 30. */ + cancelBatchSize: number; + /** Max createOrder calls per batch. Default 30. */ + createBatchSize: number; logger: pino.Logger; } @@ -54,6 +60,9 @@ export class PerpsVenueAdapter implements VenueAdapter { private readonly logger: pino.Logger; private readonly multicall3Address: `0x${string}`; + readonly readBatchSize: number; + readonly cancelBatchSize: number; + readonly createBatchSize: number; private instrumentSingleton: PerpsInstrumentAdapter | null = null; /** Cached references discovered from the DEX. */ @@ -77,6 +86,9 @@ export class PerpsVenueAdapter implements VenueAdapter { if (!mc3) throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; + this.readBatchSize = opts.readBatchSize; + this.cancelBatchSize = opts.cancelBatchSize; + this.createBatchSize = opts.createBatchSize; this.events = new PerpsVenueEvents(this.publicClient, this.address); this.account = new PerpsCollateralAccount(this); diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 6e3178c..45de014 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -160,16 +160,28 @@ export const futuresRootSchema = Type.Object( oracle: oracleSchema, timing: timingSchema, health: healthSchema, - multicallBatchSize: Type.Optional( - Type.Number({ - minimum: 1, - default: 10, - description: - "Maximum number of contract calls bundled into a single Multicall3 read. " + - "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + - "at the cost of more round-trips.", - }), - ), + readBatchSize: Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum number of contract calls bundled into a single Multicall3 read. " + + "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + + "at the cost of more round-trips.", + }), + cancelBatchSize: Type.Number({ + minimum: 1, + default: 20, + description: + "Maximum closeOrder calls per cancellation batch. " + + "The adapter groups cancels into chunks of this size before sending.", + }), + createBatchSize: Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum orders per createOrders call. " + + "The adapter packs creates into batches of this size.", + }), }, { ...Closed, description: "Titan Market Maker — Futures app config." }, ); diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index dcb28fa..b0218b9 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -45,7 +45,9 @@ async function main(): Promise { network, wallet, address: config.venue.address, - multicallBatchSize: config.multicallBatchSize, + readBatchSize: config.readBatchSize, + cancelBatchSize: config.cancelBatchSize, + createBatchSize: config.createBatchSize, logger, }); const instrument = await venue.getInstrument(); diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 4c65311..3777ac6 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -148,15 +148,29 @@ export const perpsRootSchema = Type.Object( oracle: oracleSchema, timing: timingSchema, health: healthSchema, - multicallBatchSize: Type.Optional( - Type.Number({ - minimum: 1, - default: 10, - description: - "Maximum number of contract calls bundled into a single multicall write tx. " + - "Calls are chunked transparently; lower values reduce per-tx gas cost at the cost of more txs.", - }), - ), + readBatchSize: Type.Number({ + minimum: 1, + default: 10, + description: + "Maximum number of contract calls bundled into a single Multicall3 read. " + + "Calls are chunked transparently; lower values reduce RPC timeouts.", + }), + + cancelBatchSize: Type.Number({ + minimum: 1, + default: 30, + description: + "Maximum cancelOrder calls per cancellation batch. " + + "Perps has no batch cancel — each cancel is one call.", + }), + + createBatchSize: Type.Number({ + minimum: 1, + default: 30, + description: + "Maximum createOrder calls per creation batch. " + + "Perps has no batch create — each create is one call.", + }), }, { ...Closed, description: "Titan Market Maker — Perps app config." }, ); diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index c72e933..2c6c64d 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -45,6 +45,9 @@ async function main(): Promise { network, wallet, address: config.venue.address, + readBatchSize: config.readBatchSize, + cancelBatchSize: config.cancelBatchSize, + createBatchSize: config.createBatchSize, logger, }); const instrument = await venue.getInstrument(); From 62fb448ac87e58f7db1e5fd448908be017f5b65b Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 16:30:48 +0500 Subject: [PATCH 030/180] fix(keeper): adapt to Futures v2.11.0 event/function renames - Rename Futures events: Position* -> Lot*, positionId -> lotId (LotCreated, LotClosed, LotLiquidated replace old names) - Update delivery coordinator, tracker, venues, and all tests - Fix setOrderFee -> setTakerFee in integration deploy stack - Fix perps test assertion (0 netQuantity -> 0 positions) - Fix ethUsdFeed test type error (readContract stub signature) - Share transport across integration test keepers to prevent undici socket listener accumulation (MaxListenersExceededWarning) - Accept optional shared logger in buildKeeper to avoid pino-pretty worker thread churn --- keeper/package.json | 4 +- keeper/src/config.ts | 2 +- keeper/src/delivery/coordinator.ts | 146 ++++++++++------ keeper/src/discovery/tracker.ts | 55 ++++-- keeper/src/index.ts | 2 +- keeper/src/venues/futures.ts | 34 +++- keeper/tests/delivery/coordinator.test.ts | 64 +++---- keeper/tests/discovery/tracker.test.ts | 2 +- keeper/tests/integration/buildKeeper.ts | 75 ++++++--- keeper/tests/integration/deployStack.ts | 156 +++++++++++++----- keeper/tests/integration/helpers.ts | 14 +- .../integration/keeper.integration.test.ts | 30 ++-- keeper/tests/oracle/ethUsdFeed.test.ts | 48 ++++-- keeper/tests/venues/perps.test.ts | 28 +++- 14 files changed, 441 insertions(+), 219 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 0fe2a12..950219f 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -16,7 +16,7 @@ "test:watch": "pnpm node --test --watch --test-concurrency=1 'tests/**/*.test.ts'", "pretest:integration": "pnpm node ./scripts/compile-siblings.ts", "test:integration": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/integration/**/*.test.ts'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "docker": "docker build -t collateral-margin-keeper ." }, "dependencies": { @@ -33,4 +33,4 @@ "pino-pretty": "^13.1.3" }, "packageManager": "pnpm@11.1.1" -} \ No newline at end of file +} diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 4bbd5ab..22f6acd 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -194,7 +194,7 @@ export interface Config { /** * Cadence of the periodic safety-net sweep over tracked positions. Picks * up anything the per-position timers missed (process restarts, missed - * `PositionCreated` events, clock skew). Live timers are the hot path. + * `LotCreated` events, clock skew). Live timers are the hot path. */ sweepIntervalMs: number; /** diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index 27d90af..792714c 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -33,9 +33,9 @@ import { formatGasCost } from "../tx/gasCost.ts"; * * Hot path is event-driven: * - * PositionCreated ─▶ schedule one-shot timer at deliveryAt + settleDelay - * PositionClosed ─▶ cancel the timer + drop from index - * timer fires ─▶ settle(positionId) + * LotCreated ─▶ schedule one-shot timer at deliveryAt + settleDelay + * LotClosed ─▶ cancel the timer + drop from index + * timer fires ─▶ settle(lotId) * * Cold-start safety net (two redundant paths — either alone is sufficient): * @@ -51,7 +51,7 @@ import { formatGasCost } from "../tx/gasCost.ts"; * is wired automatically in `index.ts` from * `tracker.onAdded` and once at boot from * `tracker.list()`. - * backfill(fromBlock) ─▶ replay PositionCreated/PositionClosed in + * backfill(fromBlock) ─▶ replay LotCreated/LotClosed in * chunks. Discovers positions even for * participants the tracker doesn't know * about, but breaks on rate-limited @@ -65,13 +65,13 @@ import { formatGasCost } from "../tx/gasCost.ts"; * and oracle-staleness retries. * * Single source of truth for "is this position alive": the contract emits - * `PositionClosed` at the end of every `_removePosition`, including the + * `LotClosed` at the end of every `_removePosition`, including the * cash-settlement path inside `closeDelivery` itself. The module never has * to track its own settled-set across restarts — once settled, the contract * removes the position and `getPositionById(id).seller == 0` permanently. */ export class DeliveryCoordinator { - /** Active positions known to the module: positionId → metadata. */ + /** Active positions known to the module: lotId → metadata. */ private readonly tracked = new Map(); /** One-shot timers keyed by positionId. Cleared on settle / close / stop. */ private readonly timers = new Map(); @@ -122,7 +122,7 @@ export class DeliveryCoordinator { } /** - * Subscribes to `PositionCreated` / `PositionClosed`, primes the duration + * Subscribes to `LotCreated` / `LotClosed`, primes the duration * cache, and starts the periodic safety-net sweep. Idempotent. * * Backfill is the caller's responsibility (via `backfill(fromBlock)`) so @@ -140,7 +140,10 @@ export class DeliveryCoordinator { })) as number; this.deliveryDurationSeconds = BigInt(days) * 86_400n; this.logger.info( - { deliveryDurationDays: days, blameSeller: this.config.delivery.blameSeller }, + { + deliveryDurationDays: days, + blameSeller: this.config.delivery.blameSeller, + }, "delivery coordinator starting", ); @@ -163,14 +166,14 @@ export class DeliveryCoordinator { this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "PositionCreated", - onLogs: (logs) => this.onPositionCreated(logs), + eventName: "LotCreated", + onLogs: (logs) => this.onLotCreated(logs), }), this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "PositionClosed", - onLogs: (logs) => this.onPositionClosed(logs), + eventName: "LotClosed", + onLogs: (logs) => this.onLotClosed(logs), }), ); @@ -195,7 +198,10 @@ export class DeliveryCoordinator { try { u(); } catch (err) { - this.logger.warn({ err }, "delivery: unwatcher threw — continuing shutdown"); + this.logger.warn( + { err }, + "delivery: unwatcher threw — continuing shutdown", + ); } } this.unwatchers = []; @@ -240,7 +246,7 @@ export class DeliveryCoordinator { } /** - * Replay `PositionCreated` and `PositionClosed` in `[fromBlock, head]` so + * Replay `LotCreated` and `LotClosed` in `[fromBlock, head]` so * the in-memory index reflects every position the contract still considers * active. Closed positions cancel their `created` entry as the same scan * runs in chronological order — no second pass needed. @@ -251,7 +257,9 @@ export class DeliveryCoordinator { */ async backfill(fromBlock: bigint, chunkSize: bigint): Promise { if (chunkSize <= 0n) { - throw new Error(`delivery backfill chunkSize must be positive, got ${chunkSize}`); + throw new Error( + `delivery backfill chunkSize must be positive, got ${chunkSize}`, + ); } const head = await this.chain.publicClient.getBlockNumber(); if (fromBlock > head) { @@ -282,20 +290,20 @@ export class DeliveryCoordinator { this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "PositionCreated", + eventName: "LotCreated", fromBlock: start, toBlock: end, }), this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "PositionClosed", + eventName: "LotClosed", fromBlock: start, toBlock: end, }), ]); - this.onPositionCreated(created as unknown as readonly Log[]); - this.onPositionClosed(closed as unknown as readonly Log[]); + this.onLotCreated(created as unknown as readonly Log[]); + this.onLotClosed(closed as unknown as readonly Log[]); } catch (err) { chunkErrors++; this.logger.error( @@ -423,7 +431,8 @@ export class DeliveryCoordinator { private findEarliestDeliveryAt(): bigint | undefined { let earliest: bigint | undefined; for (const pos of this.tracked.values()) { - if (earliest === undefined || pos.deliveryAt < earliest) earliest = pos.deliveryAt; + if (earliest === undefined || pos.deliveryAt < earliest) + earliest = pos.deliveryAt; } return earliest; } @@ -489,7 +498,11 @@ export class DeliveryCoordinator { let added = 0; for (let i = 0; i < ids.length; i++) { const id = ids[i] as Hex; - const pos = positions[i] as { seller: Address; buyer: Address; deliveryAt: bigint }; + const pos = positions[i] as { + seller: Address; + buyer: Address; + deliveryAt: bigint; + }; // `_removePosition` deletes the slot — `seller == 0` means already // closed/settled. Skip without touching state. if (pos.seller === zeroAddress) continue; @@ -659,7 +672,9 @@ export class DeliveryCoordinator { private async attemptBatch(positionIds: readonly Hex[]): Promise { const blameSeller = this.config.delivery.blameSeller; - type SimParams = Parameters[0]; + type SimParams = Parameters< + typeof this.chain.publicClient.simulateContract + >[0]; const simResults = await Promise.allSettled( positionIds.map((id) => this.chain.publicClient.simulateContract({ @@ -683,7 +698,10 @@ export class DeliveryCoordinator { const decoded = decodeRecoverableRevert(r.reason); if (decoded !== undefined) { this.logRecoverableRevert(decoded, id, blameSeller); - if (decoded === "PositionNotExists" || decoded === "PositionDeliveryExpired") { + if ( + decoded === "PositionNotExists" || + decoded === "PositionDeliveryExpired" + ) { this.tracked.delete(id); const t = this.timers.get(id); if (t !== undefined) { @@ -742,7 +760,9 @@ export class DeliveryCoordinator { } if (calldatas.length === 0) return; - type WriteParams = Parameters[0]; + type WriteParams = Parameters< + typeof this.chain.walletClient.writeContract + >[0]; let hash: Hex; try { // `withUnstickRetry` is the auto-recovery for the most common @@ -823,8 +843,12 @@ export class DeliveryCoordinator { const blameSeller = this.config.delivery.blameSeller; const args = [positionId, blameSeller] as const; - type SimParams = Parameters[0]; - type SimReturn = Awaited>; + type SimParams = Parameters< + typeof this.chain.publicClient.simulateContract + >[0]; + type SimReturn = Awaited< + ReturnType + >; let request: SimReturn["request"]; try { const sim = (await this.chain.publicClient.simulateContract({ @@ -841,7 +865,10 @@ export class DeliveryCoordinator { this.logRecoverableRevert(decoded, positionId, blameSeller); // PositionNotExists / PositionDeliveryExpired → contract no longer // accepts settlement. Drop from the index so we don't keep retrying. - if (decoded === "PositionNotExists" || decoded === "PositionDeliveryExpired") { + if ( + decoded === "PositionNotExists" || + decoded === "PositionDeliveryExpired" + ) { this.tracked.delete(positionId); const t = this.timers.get(positionId); if (t !== undefined) { @@ -855,13 +882,20 @@ export class DeliveryCoordinator { } if (this.config.keeper.dryRun) { - this.logger.info({ positionId, blameSeller }, "[dryRun] would call closeDelivery"); + this.logger.info( + { positionId, blameSeller }, + "[dryRun] would call closeDelivery", + ); this.tracked.delete(positionId); return; } - type WriteParams = Parameters[0]; - const hash = await this.chain.walletClient.writeContract(request as unknown as WriteParams); + type WriteParams = Parameters< + typeof this.chain.walletClient.writeContract + >[0]; + const hash = await this.chain.walletClient.writeContract( + request as unknown as WriteParams, + ); const receipt = await this.chain.publicClient.waitForTransactionReceipt({ hash, confirmations: this.config.coordinator.confirmationBlocks, @@ -889,9 +923,9 @@ export class DeliveryCoordinator { // same handler is fed both `watchContractEvent` callbacks and historical // `getContractEvents` results, so a future ABI rename surfaces here once. - private onPositionCreated(logs: readonly Log[]): void { + private onLotCreated(logs: readonly Log[]): void { type Args = { - positionId?: Hex; + lotId?: Hex; seller?: Address; buyer?: Address; deliveryAt?: bigint; @@ -901,24 +935,24 @@ export class DeliveryCoordinator { const args = (raw as unknown as { args?: Args }).args; if ( args === undefined || - args.positionId === undefined || + args.lotId === undefined || args.deliveryAt === undefined || args.seller === undefined || args.buyer === undefined ) { continue; } - const positionId = args.positionId; + const lotId = args.lotId; // Backfill can replay an event we already indexed (live watcher - // overlap). De-dupe on positionId so we don't double-schedule. - if (this.tracked.has(positionId)) continue; + // overlap). De-dupe on lotId so we don't double-schedule. + if (this.tracked.has(lotId)) continue; const tracked: TrackedPosition = { - positionId, + positionId: lotId, deliveryAt: args.deliveryAt, seller: args.seller, buyer: args.buyer, }; - this.tracked.set(positionId, tracked); + this.tracked.set(lotId, tracked); this.scheduleTimer(tracked); added++; // INFO per *new* position so the operator sees live activity in @@ -928,7 +962,7 @@ export class DeliveryCoordinator { // the dedupe `continue` above and stay silent. this.logger.info( { - positionId, + lotId, seller: args.seller, buyer: args.buyer, deliveryAt: args.deliveryAt.toString(), @@ -940,17 +974,17 @@ export class DeliveryCoordinator { if (added === 0) return; } - private onPositionClosed(logs: readonly Log[]): void { - type Args = { positionId?: Hex }; + private onLotClosed(logs: readonly Log[]): void { + type Args = { lotId?: Hex }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; - if (args?.positionId === undefined) continue; - const positionId = args.positionId; - this.tracked.delete(positionId); - const t = this.timers.get(positionId); + if (args?.lotId === undefined) continue; + const lotId = args.lotId; + this.tracked.delete(lotId); + const t = this.timers.get(lotId); if (t !== undefined) { clearTimeout(t); - this.timers.delete(positionId); + this.timers.delete(lotId); } } } @@ -991,7 +1025,10 @@ export class DeliveryCoordinator { ); return; } - if (revert === "OnlyValidatorOrPositionParticipant" || revert === "PositionDeliveryExpired") { + if ( + revert === "OnlyValidatorOrPositionParticipant" || + revert === "PositionDeliveryExpired" + ) { const key = `${revert}:${positionId}`; if (this.warned.has(key)) { this.logger.debug( @@ -1033,7 +1070,8 @@ export class DeliveryCoordinator { const existing = this.timers.get(pos.positionId); if (existing !== undefined) clearTimeout(existing); - const targetMs = Number(pos.deliveryAt) * 1000 + this.config.delivery.settleDelayMs; + const targetMs = + Number(pos.deliveryAt) * 1000 + this.config.delivery.settleDelayMs; const delayMs = Math.max(0, targetMs - Date.now()); if (delayMs > MAX_TIMEOUT_MS) { // Out of `setTimeout`'s safe range — let the sweep handle it. @@ -1046,7 +1084,10 @@ export class DeliveryCoordinator { // instead of N serial single-id txs racing for the next nonce. const timer = setTimeout(() => { void this.sweep().catch((err) => { - this.logger.error({ err, positionId: pos.positionId }, "delivery: timer-fired sweep threw"); + this.logger.error( + { err, positionId: pos.positionId }, + "delivery: timer-fired sweep threw", + ); }); }, delayMs); // Don't keep the event loop alive solely for delivery timers — the @@ -1122,9 +1163,10 @@ function decodeRecoverableRevert(err: unknown): RecoverableRevert | undefined { */ function isTransientTxError(err: unknown): boolean { if (!(err instanceof Error)) return false; - const haystack = `${err.message ?? ""} ${(err as { details?: string }).details ?? ""} ${ - (err as { shortMessage?: string }).shortMessage ?? "" - }`.toLowerCase(); + const haystack = + `${err.message ?? ""} ${(err as { details?: string }).details ?? ""} ${ + (err as { shortMessage?: string }).shortMessage ?? "" + }`.toLowerCase(); return ( haystack.includes("replacement transaction underpriced") || haystack.includes("transaction underpriced") || diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index 3029123..60602cf 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -1,4 +1,10 @@ -import { getAddress, type Address, type Hex, type Log, zeroAddress } from "viem"; +import { + getAddress, + type Address, + type Hex, + type Log, + zeroAddress, +} from "viem"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; @@ -12,7 +18,7 @@ import { FuturesAbi as futuresAbi } from "futures-marketplace/Futures.ts"; * * - Vault Deposited / Withdrawn / Transfer → adds users on first deposit * - Perps OrderCreated / OrderMatched / PositionLiquidated - * - Futures OrderCreated / PositionCreated / PositionLiquidated + * - Futures OrderCreated / LotCreated / LotLiquidated * * On startup, `backfill(fromBlock)` scans the same six events historically * via `getLogs` so the cold-start window doesn't miss participants who @@ -58,7 +64,10 @@ export class ParticipantTracker { this.logger.info("discoveryMode=webhook — RPC subscriptions disabled"); return; } - this.logger.info({ mode: this.config.chain.discoveryMode }, "starting RPC event subscriptions"); + this.logger.info( + { mode: this.config.chain.discoveryMode }, + "starting RPC event subscriptions", + ); // Each `watchContractEvent` returns an unwatcher fn; we call them all on // stop(). Vault Transfer covers both `from` and `to` so we don't need to @@ -97,8 +106,8 @@ export class ParticipantTracker { this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: futuresAbi, - eventName: "PositionCreated", - onLogs: (logs) => this.onFuturesPositionCreated(logs), + eventName: "LotCreated", + onLogs: (logs) => this.onFuturesLotCreated(logs), }), ); } @@ -125,7 +134,7 @@ export class ParticipantTracker { * dedupes on checksum. * * Futures has no `getUsersWithPositions` view on-chain, so historical - * `OrderCreated` / `PositionCreated` logs are the only source of cold- + * `OrderCreated` / `LotCreated` logs are the only source of cold- * start participants. Perps has the view but we use logs uniformly so a * single backfill mechanism covers both venues (and the vault). * @@ -236,16 +245,16 @@ export class ParticipantTracker { }, }, { - label: "futures.PositionCreated", + label: "futures.LotCreated", run: async (from, to) => { const logs = await this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: futuresAbi, - eventName: "PositionCreated", + eventName: "LotCreated", fromBlock: from, toBlock: to, }); - this.onFuturesPositionCreated(logs as unknown as readonly Log[]); + this.onFuturesLotCreated(logs as unknown as readonly Log[]); }, }, ]; @@ -253,7 +262,8 @@ export class ParticipantTracker { for (const source of sources) { let chunkErrors = 0; for (let start = fromBlock; start <= head; start += chunkSize) { - const end = start + chunkSize - 1n > head ? head : start + chunkSize - 1n; + const end = + start + chunkSize - 1n > head ? head : start + chunkSize - 1n; try { await source.run(start, end); } catch (err) { @@ -292,7 +302,10 @@ export class ParticipantTracker { const checksummed = getAddress(user); if (this.users.has(checksummed)) return false; this.users.add(checksummed); - this.logger.debug({ user: checksummed, total: this.users.size }, "tracker.add"); + this.logger.debug( + { user: checksummed, total: this.users.size }, + "tracker.add", + ); for (const l of this.addedListeners) { try { l(checksummed); @@ -340,7 +353,7 @@ export class ParticipantTracker { * Subscribe to "user state may have changed" events. Fires for the same * triggers `onAdded` does, plus any time a tracked user's state could * have shifted (vault transfer in/out, perps OrderCreated/Matched, - * futures OrderCreated/PositionCreated). + * futures OrderCreated/LotCreated). * * The predictive layer uses this to invalidate and rebuild a user's * cached MM snapshot. Listeners must tolerate being called for users @@ -409,7 +422,8 @@ export class ParticipantTracker { for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; if (args === undefined) continue; - if (args.from !== undefined && args.from !== zeroAddress) this.touch(args.from); + if (args.from !== undefined && args.from !== zeroAddress) + this.touch(args.from); if (args.to !== undefined && args.to !== zeroAddress) this.touch(args.to); } } @@ -420,7 +434,12 @@ export class ParticipantTracker { * NOTE: the perps event field is `participant`, not `user`. */ private onPerpsOrderCreated(logs: readonly Log[]): void { - type Args = { orderId?: Hex; participant?: Address; price?: bigint; quantity?: bigint }; + type Args = { + orderId?: Hex; + participant?: Address; + price?: bigint; + quantity?: bigint; + }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; if (args?.participant !== undefined) this.touch(args.participant); @@ -455,11 +474,11 @@ export class ParticipantTracker { } /** - * `PositionCreated(bytes32 indexed positionId, address indexed seller, - * address indexed buyer, uint256 sellPricePerDay, ...)`. + * `LotCreated(bytes32 indexed lotId, address indexed seller, + * address indexed buyer, uint256 pricePerDay, uint256 deliveryAt, ...)`. */ - private onFuturesPositionCreated(logs: readonly Log[]): void { - type Args = { positionId?: Hex; seller?: Address; buyer?: Address }; + private onFuturesLotCreated(logs: readonly Log[]): void { + type Args = { lotId?: Hex; seller?: Address; buyer?: Address }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; if (args === undefined) continue; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 40ff3fa..8c714d8 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -186,7 +186,7 @@ async function main(): Promise { // log-backfill pipeline, so it survives RPC providers that cap // `eth_getLogs` block ranges (Alchemy free tier = 10 blocks). Without // this hook a position created before keeper boot would only ever be - // settled if log backfill happened to find its `PositionCreated` + // settled if log backfill happened to find its `LotCreated` // event, which is unreliable on rate-limited RPCs. if (deliveryCoordinator !== undefined) { void deliveryCoordinator.indexUserPositions(user); diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 0212cb0..2b725a3 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -32,7 +32,12 @@ export class FuturesVenue implements Venue { private readonly ethUsdFeed: EthUsdFeed | undefined; private deliveryDurationDays: bigint | undefined; - constructor(chain: Chain, config: Config, logger: pino.Logger, ethUsdFeed?: EthUsdFeed) { + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + ethUsdFeed?: EthUsdFeed, + ) { this.chain = chain; this.config = config; this.logger = logger.child({ venue: "futures" }); @@ -113,7 +118,9 @@ export class FuturesVenue implements Venue { // Each position is a single contract; PnL accrues per day across the // full delivery window (matches `getFuturesUnrealizedPnl` on-chain). const isBuyer = getAddress(pos.buyer) === userAddr; - const entryPricePerDay = isBuyer ? pos.buyPricePerDay : pos.sellPricePerDay; + const entryPricePerDay = isBuyer + ? pos.buyPricePerDay + : pos.sellPricePerDay; const priceDiffPerDay = isBuyer ? marketPrice - entryPricePerDay // long: lose when market drops : entryPricePerDay - marketPrice; // short: lose when market rises @@ -130,7 +137,10 @@ export class FuturesVenue implements Venue { }); } - async liquidateOrders(user: Address, _ids?: readonly Hex[]): Promise { + async liquidateOrders( + user: Address, + _ids?: readonly Hex[], + ): Promise { // Futures sweeps FIFO until the participant is healthy — no calldata id // list needed. We deliberately ignore `ids` rather than asserting on it // so the venue surface stays uniform across perps/futures. @@ -146,10 +156,15 @@ export class FuturesVenue implements Venue { ethUsdFeed: this.ethUsdFeed, }); - return "skipped" in result ? { skipped: "notLiquidatable" } : { feeEarned: result.feeEarned }; + return "skipped" in result + ? { skipped: "notLiquidatable" } + : { feeEarned: result.feeEarned }; } - async liquidatePosition(user: Address, id: Hex): Promise { + async liquidatePosition( + user: Address, + id: Hex, + ): Promise { const result = await sendLiquidate({ chain: this.chain, config: this.config, @@ -158,7 +173,7 @@ export class FuturesVenue implements Venue { abi: FuturesAbi, functionName: "liquidatePosition", args: [user, id], - feeEventName: "PositionLiquidated", + feeEventName: "LotLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; return "notLiquidatable"; @@ -166,7 +181,9 @@ export class FuturesVenue implements Venue { ethUsdFeed: this.ethUsdFeed, }); - return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; + return "skipped" in result + ? { skipped: result.skipped } + : { feeEarned: result.feeEarned }; } /** @@ -175,7 +192,8 @@ export class FuturesVenue implements Venue { * arithmetic stays in bigint land. */ private async getDeliveryDurationDays(): Promise { - if (this.deliveryDurationDays !== undefined) return this.deliveryDurationDays; + if (this.deliveryDurationDays !== undefined) + return this.deliveryDurationDays; const days = (await this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 9870c1a..95c409b 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -71,25 +71,25 @@ function makeConfig(overrides: Partial = {}): Config { } as Config; } -interface PositionCreatedLog { +interface LotCreatedLog { args: { - positionId: Hex; + lotId: Hex; seller: Address; buyer: Address; deliveryAt: bigint; }; } -interface PositionClosedLog { - args: { positionId: Hex }; +interface LotClosedLog { + args: { lotId: Hex }; } -function positionCreatedLog(positionId: Hex, deliveryAt: bigint): PositionCreatedLog { - return { args: { positionId, seller: SELLER, buyer: BUYER, deliveryAt } }; +function lotCreatedLog(lotId: Hex, deliveryAt: bigint): LotCreatedLog { + return { args: { lotId, seller: SELLER, buyer: BUYER, deliveryAt } }; } -function positionClosedLog(positionId: Hex): PositionClosedLog { - return { args: { positionId } }; +function lotClosedLog(lotId: Hex): LotClosedLog { + return { args: { lotId } }; } interface ChainStubOptions { @@ -103,8 +103,8 @@ interface ChainStubOptions { receipt?: TransactionReceipt; /** Captures live event subscriptions so a test can flush manual logs into them. */ watchers?: { - positionCreated?: (logs: readonly PositionCreatedLog[]) => void; - positionClosed?: (logs: readonly PositionClosedLog[]) => void; + lotCreated?: (logs: readonly LotCreatedLog[]) => void; + lotClosed?: (logs: readonly LotClosedLog[]) => void; }; /** * Historical logs returned by `getContractEvents`, keyed by event name. @@ -112,8 +112,8 @@ interface ChainStubOptions { * covers the whole window, so a per-chunk dispatcher is overkill here. */ history?: { - PositionCreated?: PositionCreatedLog[]; - PositionClosed?: PositionClosedLog[]; + LotCreated?: LotCreatedLog[]; + LotClosed?: LotClosedLog[]; }; /** * View-based discovery fixtures: per-user `getPositionIds` results and @@ -213,8 +213,8 @@ function makeChain(opts: ChainStubOptions = {}): Chain & { maxPriorityFeePerGas: 100_000_000n, }), getContractEvents: async ({ eventName }: { eventName: string }) => { - if (eventName === "PositionCreated") return opts.history?.PositionCreated ?? []; - if (eventName === "PositionClosed") return opts.history?.PositionClosed ?? []; + if (eventName === "LotCreated") return opts.history?.LotCreated ?? []; + if (eventName === "LotClosed") return opts.history?.LotClosed ?? []; return []; }, watchContractEvent: ({ @@ -225,10 +225,10 @@ function makeChain(opts: ChainStubOptions = {}): Chain & { onLogs: (logs: readonly Log[]) => void; }) => { if (opts.watchers !== undefined) { - if (eventName === "PositionCreated") { - opts.watchers.positionCreated = (logs) => onLogs(logs as unknown as readonly Log[]); - } else if (eventName === "PositionClosed") { - opts.watchers.positionClosed = (logs) => onLogs(logs as unknown as readonly Log[]); + if (eventName === "LotCreated") { + opts.watchers.lotCreated = (logs) => onLogs(logs as unknown as readonly Log[]); + } else if (eventName === "LotClosed") { + opts.watchers.lotClosed = (logs) => onLogs(logs as unknown as readonly Log[]); } } return () => undefined; @@ -306,31 +306,31 @@ describe("DeliveryCoordinator: revert classification", () => { }); describe("DeliveryCoordinator: live event handling", () => { - it("indexes positions on PositionCreated and removes them on PositionClosed", async () => { + it("indexes positions on LotCreated and removes them on LotClosed", async () => { const watchers: ChainStubOptions["watchers"] = {}; const chain = makeChain({ watchers }); const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coordinator.start(); const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); + watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); assert.equal(coordinator.size(), 1); assert.ok(coordinator.has(POSITION_A)); - watchers.positionClosed?.([positionClosedLog(POSITION_A)]); + watchers.lotClosed?.([lotClosedLog(POSITION_A)]); assert.equal(coordinator.size(), 0); coordinator.stop(); }); - it("dedupes duplicate PositionCreated for the same id (live + backfill overlap)", async () => { + it("dedupes duplicate LotCreated for the same id (live + backfill overlap)", async () => { const watchers: ChainStubOptions["watchers"] = {}; const chain = makeChain({ watchers }); const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coordinator.start(); const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); - watchers.positionCreated?.([positionCreatedLog(POSITION_A, future)]); + watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); + watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); assert.equal(coordinator.size(), 1); coordinator.stop(); }); @@ -689,20 +689,20 @@ describe("DeliveryCoordinator: settleBatch()", () => { }); describe("DeliveryCoordinator: backfill", () => { - it("seeds the index from historical PositionCreated and respects subsequent PositionClosed", async () => { + it("seeds the index from historical LotCreated and respects subsequent LotClosed", async () => { const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; const chain = makeChain({ blockNumber: 1000n, simulate: () => ({ request: { ok: true } }), writeHash: "0xfeed", history: { - PositionCreated: [ - positionCreatedLog(POSITION_A, future), - positionCreatedLog(POSITION_B, future + 86_400n), - positionCreatedLog(POSITION_C, future + 2n * 86_400n), + LotCreated: [ + lotCreatedLog(POSITION_A, future), + lotCreatedLog(POSITION_B, future + 86_400n), + lotCreatedLog(POSITION_C, future + 2n * 86_400n), ], // C was already closed historically — backfill should not leave it scheduled. - PositionClosed: [positionClosedLog(POSITION_C)], + LotClosed: [lotClosedLog(POSITION_C)], }, }); const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); @@ -728,7 +728,7 @@ describe("DeliveryCoordinator: backfill", () => { }, writeHash: "0xfeed", history: { - PositionCreated: [positionCreatedLog(POSITION_A, past)], + LotCreated: [lotCreatedLog(POSITION_A, past)], }, }); const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); @@ -754,7 +754,7 @@ describe("DeliveryCoordinator: backfill", () => { writeHash: "0xfeed", deliveryDurationDays: 7, history: { - PositionCreated: [positionCreatedLog(POSITION_A, longAgo)], + LotCreated: [lotCreatedLog(POSITION_A, longAgo)], }, }); const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); diff --git a/keeper/tests/discovery/tracker.test.ts b/keeper/tests/discovery/tracker.test.ts index d7a667f..c5c0784 100644 --- a/keeper/tests/discovery/tracker.test.ts +++ b/keeper/tests/discovery/tracker.test.ts @@ -180,7 +180,7 @@ describe("ParticipantTracker: backfill", () => { [scriptKey(config.futures.address, "OrderCreated")]: [ { args: { participant: userAt(6) } }, ], - [scriptKey(config.futures.address, "PositionCreated")]: [ + [scriptKey(config.futures.address, "LotCreated")]: [ { args: { seller: userAt(7), buyer: userAt(8) } }, ], }, diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 067f540..9561b62 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -1,11 +1,12 @@ import pino from "pino"; +import pretty from "pino-pretty"; import { createPublicClient, createWalletClient, - http, type Account, type Address, type PublicClient, + type Transport, type WalletClient, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -97,38 +98,52 @@ export interface BuildKeeperOverrides { const LIQUIDATOR_PK = HARDHAT_PRIVATE_KEYS[3]; -export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverrides = {}): KeeperHarness { +export function buildKeeper( + stack: DeployedStack, + overrides: BuildKeeperOverrides = {}, + logger?: pino.Logger, +): KeeperHarness { const config = buildConfig(stack, overrides); + // Reuse the stack's publicClient so every keeper shares ONE transport — + // creating a new http() transport per keeper accumulates socket listeners + // (undici unpipe events) and triggers MaxListenersExceededWarning in CI. const chain = buildChain( - stack.rpcUrl, + stack.transport, overrides.liquidatorPrivateKey ?? LIQUIDATOR_PK, stack.addresses.multicall3, ); - const logger = pino({ - level: overrides.logLevel ?? "warn", - // The default JSON output makes test failures unreadable when tests - // are timing-sensitive. `transport: pino-pretty` would be ideal but - // requires the worker thread bootstrap — fine for dev but flaky in CI. - // We just turn off the noisy hostname/pid/time fields instead. - base: undefined, - timestamp: false, - }); + const log = + logger ?? + pino(pretty({ sync: true, colorize: true, minimumLevel: "fatal" })); const venues: Venue[] = [ - new PerpsVenue(chain, config, logger), - new FuturesVenue(chain, config, logger), + new PerpsVenue(chain, config, log), + new FuturesVenue(chain, config, log), ]; - const notifier = new Notifier(config, logger); - const tracker = new ParticipantTracker(chain, config, logger); + const notifier = new Notifier(config, log); + const tracker = new ParticipantTracker(chain, config, log); const queue = new CoordinatorQueue(); - const planner = new Planner(chain, config, venues, logger); - const executor = new CoordinatorExecutor(config, queue, planner, logger); - const scheduler = new Scheduler(chain, config, tracker, queue, executor, notifier, logger); + const planner = new Planner(chain, config, venues, log); + const executor = new CoordinatorExecutor(config, queue, planner, log); + const scheduler = new Scheduler( + chain, + config, + tracker, + queue, + executor, + notifier, + log, + ); // tokenDecimals flows from the deploy stack so the PriceFeed rescales // the BTC/USDC answer to the same units used by the venue contracts. - const priceFeed = new PriceFeed(chain, config, logger, stack.config.tokenDecimals); + const priceFeed = new PriceFeed( + chain, + config, + log, + stack.config.tokenDecimals, + ); const predictor = new PredictiveCoordinator( chain, config, @@ -136,7 +151,7 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride queue, executor, priceFeed, - logger, + log, notifier, ); @@ -147,9 +162,10 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride // Optional delivery coordinator — opt-in per test. Built but not started; // start() below boots it after the live tracker is up so it sees the same // event ordering production does. - const delivery = overrides.delivery === true - ? new DeliveryCoordinator(chain, config, logger) - : undefined; + const delivery = + overrides.delivery === true + ? new DeliveryCoordinator(chain, config, log) + : undefined; let started = false; return { @@ -188,7 +204,10 @@ export function buildKeeper(stack: DeployedStack, overrides: BuildKeeperOverride }; } -function buildConfig(stack: DeployedStack, overrides: BuildKeeperOverrides): Config { +function buildConfig( + stack: DeployedStack, + overrides: BuildKeeperOverrides, +): Config { return { chain: { network: "hardhat", @@ -268,11 +287,10 @@ function buildConfig(stack: DeployedStack, overrides: BuildKeeperOverrides): Con * uses it for batched reads, so it's a hard requirement. */ function buildChain( - rpcUrl: string, + transport: Transport, privateKey: `0x${string}`, multicall3Address: `0x${string}`, ): Chain { - const transport = http(rpcUrl); const account: Account = privateKeyToAccount(privateKey); const chainWithMulticall = { ...hardhat, @@ -281,6 +299,9 @@ function buildChain( multicall3: { address: multicall3Address }, }, }; + // Derive a new publicClient from the same transport so watchContractEvent + // polling reuses the stack's shared connection pool. The multicall3 config + // is patched onto the chain definition; the transport is inherited. const publicClient: PublicClient = createPublicClient({ chain: chainWithMulticall, transport, diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index b7a38a9..927239b 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -50,6 +50,8 @@ export interface Wallet { export interface DeployedStack { publicClient: PublicClient; testClient: TestClient; + /** The shared HTTP transport — reused by every test keeper to avoid socket listener accumulation. */ + transport: ReturnType; rpcUrl: string; accounts: { owner: Wallet; @@ -97,7 +99,7 @@ export interface DeployedStack { perpsLiquidationFee: bigint; perpsTakerFeeBps: bigint; perpsMakerFeeBps: bigint; - futuresOrderFee: bigint; + futuresTakerFee: bigint; futuresLiquidationFee: bigint; futuresDeliveryDurationDays: number; futuresFirstDeliveryDate: bigint; @@ -119,7 +121,7 @@ const MIN_PRICE_INCREMENT = parseUnits("0.01", TOKEN_DECIMALS); const PERPS_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); const PERPS_TAKER_FEE_BPS = 5n; const PERPS_MAKER_FEE_BPS = 0n; -const FUTURES_ORDER_FEE = parseUnits("1", TOKEN_DECIMALS); +const FUTURES_TAKER_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_MARGIN_PCT = 20; const FUTURES_DELIVERY_DURATION_DAYS = 7; @@ -140,7 +142,11 @@ const APPROVE_MAX = (1n << 256n) - 1n; export async function deployStack(rpcUrl: string): Promise { const transport = http(rpcUrl, { timeout: 30_000 }); const publicClient = createPublicClient({ chain: hardhat, transport }); - const testClient = createTestClient({ chain: hardhat, mode: "hardhat", transport }) + const testClient = createTestClient({ + chain: hardhat, + mode: "hardhat", + transport, + }) .extend(publicActions) .extend(walletActions); @@ -163,7 +169,12 @@ export async function deployStack(rpcUrl: string): Promise { // ── Infrastructure: Multicall3 ──────────────────────────────────────── // Deployed first because `buildKeeper` reads its address into the chain // config — keeper components must see it before they make any read call. - const multicall3 = await deploy(publicClient, owner.client, artifacts.multicall3(), []); + const multicall3 = await deploy( + publicClient, + owner.client, + artifacts.multicall3(), + [], + ); // ── Tokens & oracles ────────────────────────────────────────────────── const usdcArt = artifacts.usdc(); @@ -194,7 +205,9 @@ export async function deployStack(rpcUrl: string): Promise { // ── Perps (UUPS proxy) ──────────────────────────────────────────────── const perpsArt = artifacts.perps(); - const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [MIN_PRICE_INCREMENT]); + const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [ + MIN_PRICE_INCREMENT, + ]); const perps = await deployProxy( publicClient, owner.client, @@ -206,7 +219,9 @@ export async function deployStack(rpcUrl: string): Promise { // ── Futures (UUPS proxy, takes vault in constructor) ────────────────── const futuresArt = artifacts.futures(); - const futuresImpl = await deploy(publicClient, owner.client, futuresArt, [vault]); + const futuresImpl = await deploy(publicClient, owner.client, futuresArt, [ + vault, + ]); const latestBlock = await publicClient.getBlock(); const firstDeliveryDate = latestBlock.timestamp + BigInt(FUTURES_DELIVERY_DURATION_DAYS * 24 * 3600); @@ -232,47 +247,103 @@ export async function deployStack(rpcUrl: string): Promise { // ── PME (UUPS proxy) ────────────────────────────────────────────────── const pmeArt = artifacts.pme(); const pmeImpl = await deploy(publicClient, owner.client, pmeArt, []); - const pme = await deployProxy(publicClient, owner.client, pmeImpl, pmeArt.abi, "initialize", [ - vault, - ]); + const pme = await deployProxy( + publicClient, + owner.client, + pmeImpl, + pmeArt.abi, + "initialize", + [vault], + ); // ── Wire PME ↔ venues ↔ vault ───────────────────────────────────────── // PME -> learn about each venue so portfolio MM math includes both legs. await write(publicClient, owner.client, pme, pmeArt.abi, "setPerps", [perps]); - await write(publicClient, owner.client, pme, pmeArt.abi, "setFutures", [futures]); - - // Vault -> point at the single margin engine + authorize each venue. - await write(publicClient, owner.client, vault, vaultArt.abi, "setMarginEngine", [pme]); - await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [ - perps, - true, - ]); - await write(publicClient, owner.client, vault, vaultArt.abi, "setAuthorizedCaller", [ + await write(publicClient, owner.client, pme, pmeArt.abi, "setFutures", [ futures, - true, ]); + // Vault -> point at the single margin engine + authorize each venue. + await write( + publicClient, + owner.client, + vault, + vaultArt.abi, + "setMarginEngine", + [pme], + ); + await write( + publicClient, + owner.client, + vault, + vaultArt.abi, + "setAuthorizedCaller", + [perps, true], + ); + await write( + publicClient, + owner.client, + vault, + vaultArt.abi, + "setAuthorizedCaller", + [futures, true], + ); + // Perps -> PME + fee config. - await write(publicClient, owner.client, perps, perpsArt.abi, "setPortfolioMargin", [pme]); + await write( + publicClient, + owner.client, + perps, + perpsArt.abi, + "setPortfolioMargin", + [pme], + ); await write(publicClient, owner.client, perps, perpsArt.abi, "setMatchFee", [ Number(PERPS_TAKER_FEE_BPS), Number(PERPS_MAKER_FEE_BPS), ]); - await write(publicClient, owner.client, perps, perpsArt.abi, "setLiquidationFee", [ - PERPS_LIQUIDATION_FEE, - ]); + await write( + publicClient, + owner.client, + perps, + perpsArt.abi, + "setLiquidationFee", + [PERPS_LIQUIDATION_FEE], + ); // Futures -> PME + fees + validator URL. - await write(publicClient, owner.client, futures, futuresArt.abi, "setMarginEngine", [pme]); - await write(publicClient, owner.client, futures, futuresArt.abi, "setOrderFee", [ - FUTURES_ORDER_FEE, - ]); - await write(publicClient, owner.client, futures, futuresArt.abi, "setLiquidationFee", [ - FUTURES_LIQUIDATION_FEE, - ]); - await write(publicClient, owner.client, futures, futuresArt.abi, "setValidatorURL", [ - "//keeper-test-validator", - ]); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setMarginEngine", + [pme], + ); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setTakerFee", + [FUTURES_TAKER_FEE], + ); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setLiquidationFee", + [FUTURES_LIQUIDATION_FEE], + ); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setValidatorURL", + ["//keeper-test-validator"], + ); // ── Fund & approve test wallets ─────────────────────────────────────── for (const w of [alice, bob, liquidator, validator, dave]) { @@ -282,17 +353,26 @@ export async function deployStack(rpcUrl: string): Promise { ]); } for (const w of [owner, alice, bob, liquidator, validator, dave]) { - await write(publicClient, w.client, usdc, usdcArt.abi, "approve", [vault, APPROVE_MAX]); + await write(publicClient, w.client, usdc, usdcArt.abi, "approve", [ + vault, + APPROVE_MAX, + ]); } // ── Seed insurance fund (owner-funded) ──────────────────────────────── - await write(publicClient, owner.client, vault, vaultArt.abi, "depositInsuranceFund", [ - INSURANCE_FUND, - ]); + await write( + publicClient, + owner.client, + vault, + vaultArt.abi, + "depositInsuranceFund", + [INSURANCE_FUND], + ); return { publicClient, testClient, + transport, rpcUrl, accounts: { owner, alice, bob, liquidator, validator, dave }, addresses: { @@ -324,7 +404,7 @@ export async function deployStack(rpcUrl: string): Promise { perpsLiquidationFee: PERPS_LIQUIDATION_FEE, perpsTakerFeeBps: PERPS_TAKER_FEE_BPS, perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, - futuresOrderFee: FUTURES_ORDER_FEE, + futuresTakerFee: FUTURES_TAKER_FEE, futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, futuresDeliveryDurationDays: FUTURES_DELIVERY_DURATION_DAYS, futuresFirstDeliveryDate: firstDeliveryDate, diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index a221aab..e829850 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -173,7 +173,7 @@ export const readPerpsPositionLiquidationBlock = (s: DeployedStack, u: Address) earliestEventBlock(s, "perps", "PositionLiquidated", { user: u }); export const readFuturesPositionLiquidationBlock = (s: DeployedStack, u: Address) => - earliestEventBlock(s, "futures", "PositionLiquidated", { participant: u }); + earliestEventBlock(s, "futures", "LotLiquidated", { participant: u }); export const readPerpsOrderLiquidationBlock = (s: DeployedStack, u: Address) => earliestEventBlock(s, "perps", "OrderLiquidated", { user: u }); @@ -182,19 +182,19 @@ export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) = earliestEventBlock(s, "futures", "OrderLiquidated", { participant: u }); /** - * Earliest block at which `Futures.PositionDeliveryClosed(positionId)` was + * Earliest block at which `Futures.LotClosed(lotId)` was * emitted. Used by the delivery-coordinator e2e tests to confirm the keeper * actually sent `closeDelivery` for a specific position id. */ -export async function readPositionDeliveryClosedBlock( +export async function readLotClosedBlock( stack: DeployedStack, - positionId: Hex, + lotId: Hex, ): Promise { const logs = await stack.publicClient.getContractEvents({ address: stack.addresses.futures, abi: stack.abis.futures, - eventName: "PositionDeliveryClosed", - args: { positionId }, + eventName: "LotClosed", + args: { lotId }, fromBlock: 0n, }); let earliest: bigint | null = null; @@ -208,7 +208,7 @@ export async function readPositionDeliveryClosedBlock( async function earliestEventBlock( stack: DeployedStack, venue: "perps" | "futures", - eventName: "PositionLiquidated" | "OrderLiquidated", + eventName: "PositionLiquidated" | "LotLiquidated" | "OrderLiquidated", args: Record, ): Promise { const logs = await stack.publicClient.getContractEvents({ diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 5ffee48..5cc702e 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -36,7 +36,7 @@ import { readFuturesPositionLiquidationBlock, readPerpsOrderLiquidationBlock, readFuturesOrderLiquidationBlock, - readPositionDeliveryClosedBlock, + readLotClosedBlock, readPerpsPosition, expectPerpsClosed, expectFuturesClosed, @@ -400,7 +400,7 @@ describe("Cross-venue coordination", () => { const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event"); assert.ok( perpsBlock < futuresBlock, `expected perps liquidated before futures, got perps=${perpsBlock} futures=${futuresBlock}`, @@ -431,7 +431,7 @@ describe("Cross-venue coordination", () => { const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event"); assert.ok( futuresBlock < perpsBlock, `expected futures liquidated before perps, got perps=${perpsBlock} futures=${futuresBlock}`, @@ -488,7 +488,7 @@ describe("Cross-venue coordination", () => { assert.ok(perpsOrderBlock !== null, "expected a perps OrderLiquidated event"); assert.ok(futuresOrderBlock !== null, "expected a futures OrderLiquidated event"); assert.ok(perpsPositionBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresPositionBlock !== null, "expected a futures PositionLiquidated event"); + assert.ok(futuresPositionBlock !== null, "expected a futures LotLiquidated event"); const latestOrderBlock = max(perpsOrderBlock, futuresOrderBlock); const earliestPositionBlock = min(perpsPositionBlock, futuresPositionBlock); @@ -626,8 +626,8 @@ describe("DeliveryCoordinator (live RPC)", () => { // We then fast-forward the chain past `deliveryAt` and trigger one // sweep. The contract's `_closeAndCashSettleDelivery` cash-settles // the entire window at the current market price (positionElapsedTime - // = 0 → no contract-price portion), and emits `PositionDeliveryClosed` - // followed by `PositionClosed`. + // = 0 → no contract-price portion), and emits `LotClosed` + // followed by `LotClosed`. const ctx = await loadFixture(futuresLongCrashFixture, testClient); keeper = buildKeeper(ctx, { liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator @@ -668,22 +668,22 @@ describe("DeliveryCoordinator (live RPC)", () => { await keeper.delivery.sweep(); // End state: every position is gone from chain storage, each emitted - // a `PositionDeliveryClosed` event from the keeper's signer, and the + // a `LotClosed` event from the keeper's signer, and the // index dropped all of them. await expectFuturesClosed(ctx, alice); const settledBlocks: bigint[] = []; for (const id of positionsBefore) { - const settledBlock = await readPositionDeliveryClosedBlock(ctx, id); + const settledBlock = await readLotClosedBlock(ctx, id); assert.ok( settledBlock !== null, - `expected a PositionDeliveryClosed event for position ${id}`, + `expected a LotClosed event for position ${id}`, ); settledBlocks.push(settledBlock); assert.equal(keeper.delivery.has(id), false, `settled position ${id} is dropped`); } // Batching invariant: all 12 settlements ride a single // `Futures.multicall(bytes[])` transaction, so every - // `PositionDeliveryClosed` event lands in the same block. Without + // `LotClosed` event lands in the same block. Without // batching they would have been N separate txs across N blocks // (plus a `replacement transaction underpriced` race in production // when two of them collided on the same nonce). This assertion @@ -714,7 +714,7 @@ describe("DeliveryCoordinator (live RPC)", () => { const ctx = await loadFixture(futuresLongCrashFixture, testClient); // Move time past deliveryAt *before* the keeper boots, so the live - // subscription would miss the (long-past) PositionCreated event. + // subscription would miss the (long-past) LotCreated event. const deliveryAt = ctx.config.futuresFirstDeliveryDate; await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); await testClient.mine({ blocks: 1 }); @@ -740,7 +740,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, id)) !== null, `missed delivery for ${id} should be settled by backfill sweep`, ); } @@ -785,7 +785,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, id)) !== null, `position ${id} should be settled via view-based bootstrap`, ); assert.equal(keeper.delivery.has(id), false); @@ -838,7 +838,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readPositionDeliveryClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, id)) !== null, `manually-seeded position ${id} should be settled`, ); } @@ -876,7 +876,7 @@ describe("DeliveryCoordinator (live RPC)", () => { assert.ok(positionsBefore.length > 0, "fixture should have created positions"); for (const id of positionsBefore) { assert.equal( - await readPositionDeliveryClosedBlock(ctx, id), + await readLotClosedBlock(ctx, id), null, `position ${id} must not be settled by a misconfigured keeper`, ); diff --git a/keeper/tests/oracle/ethUsdFeed.test.ts b/keeper/tests/oracle/ethUsdFeed.test.ts index 2dc341e..cad0a4c 100644 --- a/keeper/tests/oracle/ethUsdFeed.test.ts +++ b/keeper/tests/oracle/ethUsdFeed.test.ts @@ -17,7 +17,10 @@ interface FakeReads { failNextAnswerReadCount?: number; } -function makeChain(reads: FakeReads): { chain: Chain; calls: { decimals: number; latest: number } } { +function makeChain(reads: FakeReads): { + chain: Chain; + calls: { decimals: number; latest: number }; +} { const calls = { decimals: 0, latest: 0 }; const decimals = reads.decimals ?? 8; const chain = { @@ -34,7 +37,8 @@ function makeChain(reads: FakeReads): { chain: Chain; calls: { decimals: number; if (functionName === "latestRoundData") { calls.latest++; if ((reads.failNextAnswerReadCount ?? 0) > 0) { - reads.failNextAnswerReadCount = (reads.failNextAnswerReadCount ?? 0) - 1; + reads.failNextAnswerReadCount = + (reads.failNextAnswerReadCount ?? 0) - 1; throw new Error("latestRoundData rpc failed"); } const i = Math.min(calls.latest - 1, reads.answers.length - 1); @@ -62,7 +66,10 @@ describe("EthUsdFeed", () => { it("populates current() and updatedAt() after a successful refresh", async () => { const before = Date.now(); - const { chain, calls } = makeChain({ answers: [3000_00000000n], decimals: 8 }); + const { chain, calls } = makeChain({ + answers: [3000_00000000n], + decimals: 8, + }); const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); await feed.refresh(); assert.equal(feed.current(), 3000_00000000n); @@ -73,11 +80,17 @@ describe("EthUsdFeed", () => { }); it("reads decimals only once and reuses it across refreshes", async () => { - const { chain, calls } = makeChain({ answers: [2500_00000000n, 2600_00000000n] }); + const { chain, calls } = makeChain({ + answers: [2500_00000000n, 2600_00000000n], + }); const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); await feed.refresh(); await feed.refresh(); - assert.equal(calls.decimals, 1, "decimals are immutable on Chainlink — read once"); + assert.equal( + calls.decimals, + 1, + "decimals are immutable on Chainlink — read once", + ); assert.equal(calls.latest, 2); feed.stop(); }); @@ -87,19 +100,26 @@ describe("EthUsdFeed", () => { const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); await feed.refresh(); await feed.refresh(); - assert.equal(feed.current(), 3000_00000000n, "non-positive answer should NOT clobber the price"); + assert.equal( + feed.current(), + 3000_00000000n, + "non-positive answer should NOT clobber the price", + ); feed.stop(); }); it("keeps the previous price when the RPC throws — feed is never fatal for logging", async () => { - const { chain } = makeChain({ answers: [3000_00000000n], failNextAnswerReadCount: 0 }); + const { chain } = makeChain({ + answers: [3000_00000000n], + failNextAnswerReadCount: 0, + }); const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); await feed.refresh(); - // Next refresh fails, but `current()` should still report the prior price. chain.publicClient.readContract = async ({ functionName }) => { - if (functionName === "decimals") return 8; + if (functionName === "decimals") return 8 as any; throw new Error("rpc down"); }; + // Next refresh fails, but `current()` should still report the prior price. await feed.refresh(); assert.equal(feed.current(), 3000_00000000n); feed.stop(); @@ -149,13 +169,19 @@ describe("EthUsdFeed", () => { await feed.refresh(); const usd = feed.weiToUsd(10n ** 9n); assert.ok(usd !== undefined); - assert.ok((usd as number) > 0, "1 gwei equivalent should not round down to zero"); + assert.ok( + (usd as number) > 0, + "1 gwei equivalent should not round down to zero", + ); feed.stop(); }); it("handles non-standard oracle decimals (e.g. 18)", async () => { // ETH/USD = $3000 with 18 decimals → raw answer 3000e18. - const { chain } = makeChain({ answers: [3000n * 10n ** 18n], decimals: 18 }); + const { chain } = makeChain({ + answers: [3000n * 10n ** 18n], + decimals: 18, + }); const feed = new EthUsdFeed(chain, FEED_ADDR, SILENT, 60_000); await feed.refresh(); assert.equal(feed.weiToUsd(10n ** 18n), 3000); diff --git a/keeper/tests/venues/perps.test.ts b/keeper/tests/venues/perps.test.ts index f878b1c..aed92ca 100644 --- a/keeper/tests/venues/perps.test.ts +++ b/keeper/tests/venues/perps.test.ts @@ -48,7 +48,11 @@ const silentLogger = { describe("perps venue: marketLabel", () => { it("always returns 'perps' regardless of marketId", () => { - const venue = new PerpsVenue(makeChainStub({}), makeConfigStub(), silentLogger); + const venue = new PerpsVenue( + makeChainStub({}), + makeConfigStub(), + silentLogger, + ); assert.equal(venue.marketLabel(PERPS_MARKET_ID), "perps"); // Even an unrelated marketId resolves to the single perps label. assert.equal(venue.marketLabel(pad("0xdead", { size: 32 })), "perps"); @@ -97,7 +101,7 @@ describe("perps venue: readPositions", () => { }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const positions = await venue.readPositions(USER); - assert.equal(positions.length, 1); + assert.equal(positions.length, 0); }); it("computes unrealizedLoss=0 and notional=marketPrice*qty for a profitable long", async () => { @@ -105,7 +109,10 @@ describe("perps venue: readPositions", () => { const entryPrice = 100n; const marketPrice = 150n; // up → long is in profit, no loss const chain = makeChainStub({ - multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -119,7 +126,10 @@ describe("perps venue: readPositions", () => { const entryPrice = 200n; const marketPrice = 150n; // -50 per contract × 3 contracts = 150 loss const chain = makeChainStub({ - multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -133,7 +143,10 @@ describe("perps venue: readPositions", () => { const entryPrice = 100n; const marketPrice = 130n; // +30 against the short × 4 = 120 loss const chain = makeChainStub({ - multicall: () => [{ netQuantity: qty, aggregatedEntryPrice: entryPrice }, marketPrice], + multicall: () => [ + { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + marketPrice, + ], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); @@ -144,7 +157,10 @@ describe("perps venue: readPositions", () => { it("synthesises a deterministic positionId from the user address (bytes32(user))", async () => { const chain = makeChainStub({ - multicall: () => [{ netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, 100n], + multicall: () => [ + { netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, + 100n, + ], }); const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(USER); From 350c6615df7fa41bf3ecb3d19f20110ef4e1022b Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 16:46:57 +0500 Subject: [PATCH 031/180] ci(keeper): add test + deploy pipelines, switch to GitHub deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add keeper-test.yml: single job with typecheck, unit, integration tests - Add deploy-keeper.yml: dual-target (futures + perps) ECS deployment - Switch package.json deps from link: to github: protocol URLs - Remove postinstall fix-placeholder-manifests (no longer needed) - No sibling repo checkouts required — pnpm fetches ABIs from GitHub --- .github/workflows/deploy-keeper.yml | 432 ++++++++++++++++++++++++++++ .github/workflows/keeper-test.yml | 61 ++++ keeper/package.json | 7 +- 3 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy-keeper.yml create mode 100644 .github/workflows/keeper-test.yml diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml new file mode 100644 index 0000000..05d9677 --- /dev/null +++ b/.github/workflows/deploy-keeper.yml @@ -0,0 +1,432 @@ +name: Deploy Keeper + +on: + push: + branches: + - dev + - stg + - main + paths: + - "keeper/**" + - ".github/workflows/deploy-keeper.yml" + + workflow_dispatch: + inputs: + target: + description: "Which venue keeper to deploy" + required: true + type: choice + options: + - both + - futures + - perps + environment: + description: "Target environment" + required: true + type: choice + options: + - dev + - stg + - main + +permissions: + id-token: write + contents: write + packages: write + +env: + GHCR_REGISTRY: ghcr.io + GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper + +jobs: + # ── Build (shared across both targets) ───────────────────────────────── + build: + name: 🔨 Build + runs-on: ubuntu-latest + outputs: + version: ${{ steps.gen_tag.outputs.version }} + tag: ${{ steps.gen_tag.outputs.tag_name }} + environment: ${{ steps.gen_tag.outputs.environment }} + is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Generate version tag + id: gen_tag + uses: ./.github/actions/gen-tag + with: + component: keeper + major_version: "1" + environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker tags + id: docker_tags + run: | + TAGS="${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.version }} + ${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.environment }}-latest" + + # Add 'latest' tag for main branch + if [ "${{ steps.gen_tag.outputs.environment }}" == "main" ]; then + TAGS="${TAGS} + ${{ env.GHCR_IMAGE }}:latest" + fi + + echo "tags<> $GITHUB_OUTPUT + echo "$TAGS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./keeper + push: ${{ steps.gen_tag.outputs.is_cicd_branch != 'true' }} + load: ${{ steps.gen_tag.outputs.is_cicd_branch == 'true' }} + tags: ${{ steps.docker_tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + labels: | + org.opencontainers.image.source=${{ github.repositoryUrl }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.gen_tag.outputs.version }} + + - name: CI/CD test summary + if: steps.gen_tag.outputs.is_cicd_branch == 'true' + run: | + echo "## 🔧 CI/CD Test Build Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** Test only (no deployment)" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.gen_tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + # ── Deploy: Futures Keeper ────────────────────────────────────────── + deploy-futures: + name: 🚀 Deploy Futures Keeper + runs-on: ubuntu-latest + needs: build + if: | + needs.build.outputs.is_cicd_branch != 'true' && + (github.event_name != 'workflow_dispatch' || github.event.inputs.target == 'both' || github.event.inputs.target == 'futures') + environment: ${{ needs.build.outputs.environment }} + steps: + - name: Environment config + id: cfg + run: | + ENV="${{ needs.build.outputs.environment }}" + case $ENV in + dev) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-dev" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-futures-dev" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT + ;; + stg) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-stg" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-futures-stg" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT + ;; + main) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-lmn" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-futures-lmn" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT + ;; + esac + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ steps.cfg.outputs.aws_region }} + role-session-name: GitHubActions-KeeperFutures-${{ github.run_id }} + + - name: Deploy to ECS + env: + KEEPER_DRY_RUN: ${{ vars.KEEPER_FUTURES_DRY_RUN || 'false' }} + KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_FUTURES_MIN_PROFIT_MARGIN || '0' }} + FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} + DELIVERY_KEEPER_ENABLED: ${{ vars.KEEPER_FUTURES_DELIVERY_ENABLED || 'false' }} + run: | + CLUSTER="${{ steps.cfg.outputs.ecs_cluster }}" + SERVICE="${{ steps.cfg.outputs.ecs_service }}" + TASK_FAMILY="${{ steps.cfg.outputs.task_family }}" + IMAGE="${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}" + REGION="${{ steps.cfg.outputs.aws_region }}" + + echo "🚀 Deploying Futures Keeper to ${{ needs.build.outputs.environment }}" + echo " Cluster: ${CLUSTER}" + echo " Service: ${SERVICE}" + echo " Image: ${IMAGE}" + + aws ecs describe-task-definition \ + --task-definition ${TASK_FAMILY} \ + --region ${REGION} \ + --query 'taskDefinition' > task-def.json + + jq --arg IMAGE "${IMAGE}" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ + task-def.json > new-task-def.json + + jq -n '[ + { name: "KEEPER_DRY_RUN", value: env.KEEPER_DRY_RUN }, + { name: "KEEPER_MIN_PROFIT_MARGIN", value: env.KEEPER_MIN_PROFIT_MARGIN }, + { name: "FUTURES_ADDRESS", value: env.FUTURES_ADDRESS }, + { name: "DELIVERY_KEEPER_ENABLED", value: env.DELIVERY_KEEPER_ENABLED } + ]' > env-overrides.json + + jq --slurpfile overrides env-overrides.json ' + .containerDefinitions[0].environment as $existing | + ($overrides[0] | map({(.name): .}) | add) as $override_map | + .containerDefinitions[0].environment = ( + [$existing[] | if $override_map[.name] then .value = $override_map[.name].value else . end] + + [$overrides[0][] | select(.name as $n | [$existing[].name] | index($n) | not)] + ) + ' new-task-def.json > final-task-def.json + mv final-task-def.json new-task-def.json + + NEW_TASK_DEF=$(aws ecs register-task-definition \ + --cli-input-json file://new-task-def.json \ + --region ${REGION} \ + --query 'taskDefinition.taskDefinitionArn' --output text) + + echo "✅ Registered: ${NEW_TASK_DEF}" + + aws ecs update-service \ + --cluster ${CLUSTER} \ + --service ${SERVICE} \ + --task-definition ${NEW_TASK_DEF} \ + --region ${REGION} \ + --force-new-deployment + + echo "✅ Deployment triggered" + + # ── Deploy: Perps Keeper ──────────────────────────────────────────── + deploy-perps: + name: 🚀 Deploy Perps Keeper + runs-on: ubuntu-latest + needs: build + if: | + needs.build.outputs.is_cicd_branch != 'true' && + (github.event_name != 'workflow_dispatch' || github.event.inputs.target == 'both' || github.event.inputs.target == 'perps') + environment: ${{ needs.build.outputs.environment }} + steps: + - name: Environment config + id: cfg + run: | + ENV="${{ needs.build.outputs.environment }}" + case $ENV in + dev) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-dev" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-perps-dev" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT + ;; + stg) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-stg" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-perps-stg" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT + ;; + main) + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-collateral-margin-lmn" >> $GITHUB_OUTPUT + echo "ecs_service=svc-keeper-perps-lmn" >> $GITHUB_OUTPUT + echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT + ;; + esac + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ steps.cfg.outputs.aws_region }} + role-session-name: GitHubActions-KeeperPerps-${{ github.run_id }} + + - name: Deploy to ECS + env: + KEEPER_DRY_RUN: ${{ vars.KEEPER_PERPS_DRY_RUN || 'false' }} + KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_PERPS_MIN_PROFIT_MARGIN || '0' }} + PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} + run: | + CLUSTER="${{ steps.cfg.outputs.ecs_cluster }}" + SERVICE="${{ steps.cfg.outputs.ecs_service }}" + TASK_FAMILY="${{ steps.cfg.outputs.task_family }}" + IMAGE="${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}" + REGION="${{ steps.cfg.outputs.aws_region }}" + + echo "🚀 Deploying Perps Keeper to ${{ needs.build.outputs.environment }}" + echo " Cluster: ${CLUSTER}" + echo " Service: ${SERVICE}" + echo " Image: ${IMAGE}" + + aws ecs describe-task-definition \ + --task-definition ${TASK_FAMILY} \ + --region ${REGION} \ + --query 'taskDefinition' > task-def.json + + jq --arg IMAGE "${IMAGE}" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ + task-def.json > new-task-def.json + + jq -n '[ + { name: "KEEPER_DRY_RUN", value: env.KEEPER_DRY_RUN }, + { name: "KEEPER_MIN_PROFIT_MARGIN", value: env.KEEPER_MIN_PROFIT_MARGIN }, + { name: "PERPS_ADDRESS", value: env.PERPS_ADDRESS } + ]' > env-overrides.json + + jq --slurpfile overrides env-overrides.json ' + .containerDefinitions[0].environment as $existing | + ($overrides[0] | map({(.name): .}) | add) as $override_map | + .containerDefinitions[0].environment = ( + [$existing[] | if $override_map[.name] then .value = $override_map[.name].value else . end] + + [$overrides[0][] | select(.name as $n | [$existing[].name] | index($n) | not)] + ) + ' new-task-def.json > final-task-def.json + mv final-task-def.json new-task-def.json + + NEW_TASK_DEF=$(aws ecs register-task-definition \ + --cli-input-json file://new-task-def.json \ + --region ${REGION} \ + --query 'taskDefinition.taskDefinitionArn' --output text) + + echo "✅ Registered: ${NEW_TASK_DEF}" + + aws ecs update-service \ + --cluster ${CLUSTER} \ + --service ${SERVICE} \ + --task-definition ${NEW_TASK_DEF} \ + --region ${REGION} \ + --force-new-deployment + + echo "✅ Deployment triggered" + + # ── Verify (futures) ───────────────────────────────────────────────── + verify-futures: + name: 🔍 Verify Futures + runs-on: ubuntu-latest + needs: [build, deploy-futures] + if: | + always() && + needs.build.outputs.is_cicd_branch != 'true' && + needs.deploy-futures.result == 'success' + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: us-east-1 + role-session-name: GitHubActions-KeeperFutures-Verify-${{ github.run_id }} + + - name: Wait for service to stabilize + run: | + ENV="${{ needs.build.outputs.environment }}" + case $ENV in + dev) CLUSTER="ecs-collateral-margin-dev"; SERVICE="svc-keeper-futures-dev" ;; + stg) CLUSTER="ecs-collateral-margin-stg"; SERVICE="svc-keeper-futures-stg" ;; + main) CLUSTER="ecs-collateral-margin-lmn"; SERVICE="svc-keeper-futures-lmn" ;; + esac + aws ecs wait services-stable \ + --cluster ${CLUSTER} \ + --services ${SERVICE} \ + --region us-east-1 + echo "✅ Futures keeper stable" + + # ── Verify (perps) ─────────────────────────────────────────────────── + verify-perps: + name: 🔍 Verify Perps + runs-on: ubuntu-latest + needs: [build, deploy-perps] + if: | + always() && + needs.build.outputs.is_cicd_branch != 'true' && + needs.deploy-perps.result == 'success' + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + aws-region: us-east-1 + role-session-name: GitHubActions-KeeperPerps-Verify-${{ github.run_id }} + + - name: Wait for service to stabilize + run: | + ENV="${{ needs.build.outputs.environment }}" + case $ENV in + dev) CLUSTER="ecs-collateral-margin-dev"; SERVICE="svc-keeper-perps-dev" ;; + stg) CLUSTER="ecs-collateral-margin-stg"; SERVICE="svc-keeper-perps-stg" ;; + main) CLUSTER="ecs-collateral-margin-lmn"; SERVICE="svc-keeper-perps-lmn" ;; + esac + aws ecs wait services-stable \ + --cluster ${CLUSTER} \ + --services ${SERVICE} \ + --region us-east-1 + echo "✅ Perps keeper stable" + + # ── Tag + notify ───────────────────────────────────────────────────── + cleanup: + name: 🧹 Tag & Notify + runs-on: ubuntu-latest + needs: [build, deploy-futures, deploy-perps, verify-futures, verify-perps] + if: always() && needs.build.outputs.is_cicd_branch != 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + if: needs.verify-futures.result == 'success' || needs.verify-perps.result == 'success' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: needs.verify-futures.result == 'success' || needs.verify-perps.result == 'success' + run: | + TAG_NAME="${{ needs.build.outputs.tag }}" + echo "🏷️ Creating tag: $TAG_NAME" + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "⚠️ Tag already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${{ needs.build.outputs.version }}" + git push origin "$TAG_NAME" + echo "✅ Tag pushed" + fi + + - name: Deployment summary + run: | + echo "## 🎉 Keeper Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ needs.build.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Image:** \`${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.verify-futures.result }}" == "success" ]; then + echo "✅ Futures keeper deployed and stable" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Futures keeper: ${{ needs.verify-futures.result }}" >> $GITHUB_STEP_SUMMARY + fi + if [ "${{ needs.verify-perps.result }}" == "success" ]; then + echo "✅ Perps keeper deployed and stable" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Perps keeper: ${{ needs.verify-perps.result }}" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml new file mode 100644 index 0000000..e1be7ed --- /dev/null +++ b/.github/workflows/keeper-test.yml @@ -0,0 +1,61 @@ +name: Keeper Tests + +on: + push: + branches: [main, dev, stg] + paths: + - "keeper/**" + - ".github/workflows/keeper-test.yml" + + pull_request: + paths: + - "keeper/**" + - ".github/workflows/keeper-test.yml" + + workflow_dispatch: + +concurrency: + group: keeper-test-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: "11" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: keeper/pnpm-lock.yaml + + - name: Install dependencies + working-directory: ./keeper + run: pnpm install --frozen-lockfile + + - name: TypeCheck + working-directory: ./keeper + run: pnpm typecheck + + - name: Unit tests + working-directory: ./keeper + run: pnpm test + + - name: Integration tests + working-directory: ./keeper + run: pnpm test:integration diff --git a/keeper/package.json b/keeper/package.json index 950219f..1808a05 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -7,7 +7,6 @@ "node": ">=22.6.0" }, "scripts": { - "postinstall": "node ./scripts/fix-placeholder-manifests.ts", "node": "node --import=amaro/strip", "dev": "pnpm node --env-file=../.env src/index.ts | pino-pretty", "dev:dry": "DRY_RUN=true pnpm node --env-file=.env src/index.ts | pino-pretty", @@ -21,9 +20,9 @@ }, "dependencies": { "amaro": "^1.1.9", - "collateral-margin": "link:../contracts/abi", - "derivatives-marketplace": "link:../../perps/contracts/abi", - "futures-marketplace": "link:../../futures-marketplace/contracts/abi", + "collateral-margin": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", + "derivatives-marketplace": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", + "futures-marketplace": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, From 8b5750b64f726b270b20dd59df0f1eb931348680 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 18:18:32 +0500 Subject: [PATCH 032/180] refactor(keeper): switch ABI deps to GitHub sources with pnpm patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ABI packages: collateral-margin → collateral-margin-abi, etc. - Add pnpm patches for GitHub-sourced ABI packages - Bump viem to 2.51.0 - Update all imports to use new package names - Improve outdated order sweeper logic and tests - General formatting and cleanup --- keeper/package.json | 9 +- keeper/patches/collateral-margin-abi@.patch | 13 ++ .../derivatives-marketplace-abi@.patch | 13 ++ keeper/patches/futures-marketplace-abi@.patch | 13 ++ keeper/pnpm-lock.yaml | 80 +++++--- keeper/pnpm-workspace.yaml | 9 + keeper/scripts/audit-indexer-sync.ts | 68 +++++-- keeper/scripts/debug-delivery-bootstrap.ts | 18 +- keeper/src/delivery/coordinator.ts | 2 +- keeper/src/discovery/tracker.ts | 6 +- keeper/src/index.ts | 2 +- keeper/src/pme/health.ts | 9 +- keeper/src/predict/snapshot.ts | 13 +- keeper/src/runtime/outdatedOrderSweeper.ts | 19 +- keeper/src/venues/futures.ts | 2 +- keeper/src/venues/perps.ts | 44 ++++- .../runtime/outdatedOrderSweeper.test.ts | 171 ++++++++++++++---- 17 files changed, 376 insertions(+), 115 deletions(-) create mode 100644 keeper/patches/collateral-margin-abi@.patch create mode 100644 keeper/patches/derivatives-marketplace-abi@.patch create mode 100644 keeper/patches/futures-marketplace-abi@.patch diff --git a/keeper/package.json b/keeper/package.json index 1808a05..4093330 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -20,9 +20,9 @@ }, "dependencies": { "amaro": "^1.1.9", - "collateral-margin": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", - "derivatives-marketplace": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", - "futures-marketplace": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", + "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, @@ -31,5 +31,6 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" }, - "packageManager": "pnpm@11.1.1" + "packageManager": "pnpm@11.1.1", + "pnpm": {} } diff --git a/keeper/patches/collateral-margin-abi@.patch b/keeper/patches/collateral-margin-abi@.patch new file mode 100644 index 0000000..9aafff8 --- /dev/null +++ b/keeper/patches/collateral-margin-abi@.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +new file mode 100644 +index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 +--- /dev/null ++++ b/package.json +@@ -0,0 +1,7 @@ ++{ ++ "name": "collateral-margin-abi", ++ "type": "module", ++ "exports": { ++ "./*": "./*" ++ } ++} diff --git a/keeper/patches/derivatives-marketplace-abi@.patch b/keeper/patches/derivatives-marketplace-abi@.patch new file mode 100644 index 0000000..57ad5d6 --- /dev/null +++ b/keeper/patches/derivatives-marketplace-abi@.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +new file mode 100644 +index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 +--- /dev/null ++++ b/package.json +@@ -0,0 +1,7 @@ ++{ ++ "name": "derivatives-marketplace-abi", ++ "type": "module", ++ "exports": { ++ "./*": "./*" ++ } ++} diff --git a/keeper/patches/futures-marketplace-abi@.patch b/keeper/patches/futures-marketplace-abi@.patch new file mode 100644 index 0000000..e298abb --- /dev/null +++ b/keeper/patches/futures-marketplace-abi@.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +new file mode 100644 +index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 +--- /dev/null ++++ b/package.json +@@ -0,0 +1,7 @@ ++{ ++ "name": "futures-marketplace-abi", ++ "type": "module", ++ "exports": { ++ "./*": "./*" ++ } ++} diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index b3a40a9..b062f54 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false +patchedDependencies: + collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d + derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d + futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a + importers: .: @@ -11,21 +16,21 @@ importers: amaro: specifier: ^1.1.9 version: 1.1.9 - collateral-margin: - specifier: link:../contracts/abi - version: link:../contracts/abi - derivatives-marketplace: - specifier: link:../../perps/contracts/abi - version: link:../../perps/contracts/abi - futures-marketplace: - specifier: link:../../futures-marketplace/contracts/abi - version: link:../../futures-marketplace/contracts/abi + collateral-margin-abi: + specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) + derivatives-marketplace-abi: + specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + futures-marketplace-abi: + specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 viem: specifier: ^2.48.8 - version: 2.48.11 + version: 2.51.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -135,12 +140,20 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6} + version: 0.0.0 + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-Bfl5FyD8XUbzr0oLvEPR9DsygBNN6eoI2oPVaJP51r7l1KNT6ZoeO8c8QOud8/58R2AgMR94qU+s+47SlDJIOQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487} + version: 0.0.0 + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -153,6 +166,10 @@ 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/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c} + version: 0.0.0 + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -175,8 +192,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - ox@0.14.20: - resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: + resolution: {tarball: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a} + version: 0.14.26-386a343.0 peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -231,15 +249,15 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - thread-stream@4.1.0: - resolution: {integrity: sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - viem@2.48.11: - resolution: {integrity: sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw==} + viem@2.51.0: + resolution: {integrity: sha512-8C0Ca+eEapXE29vHMUW59NqKENl1X4s9P6xSNC9Nvw6EvAeAhn/LNUlgztk6TOw7KN1Gzz5a/n9Wv4okUfmY9g==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -249,8 +267,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -329,10 +347,14 @@ snapshots: atomic-sleep@1.0.0: {} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} + colorette@2.0.20: {} dateformat@4.6.3: {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -343,11 +365,13 @@ snapshots: fast-safe-stringify@2.1.1: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + help-me@5.0.0: {} - isows@1.0.7(ws@8.18.3): + isows@1.0.7(ws@8.20.1): dependencies: - ws: 8.18.3 + ws: 8.20.1 joycon@3.1.1: {} @@ -359,7 +383,7 @@ snapshots: dependencies: wrappy: 1.0.2 - ox@0.14.20: + ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -406,7 +430,7 @@ snapshots: real-require: 0.2.0 safe-stable-stringify: 2.5.0 sonic-boom: 4.2.1 - thread-stream: 4.1.0 + thread-stream: 4.2.0 process-warning@5.0.0: {} @@ -433,22 +457,22 @@ snapshots: strip-json-comments@5.0.3: {} - thread-stream@4.1.0: + thread-stream@4.2.0: dependencies: real-require: 1.0.0 undici-types@6.21.0: {} - viem@2.48.11: + viem@2.51.0: dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 abitype: 1.2.3 - isows: 1.0.7(ws@8.18.3) - ox: 0.14.20 - ws: 8.18.3 + isows: 1.0.7(ws@8.20.1) + ox: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -456,4 +480,4 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} + ws@8.20.1: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index 1aec1d9..8b68120 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -1 +1,10 @@ +allowBuilds: + "@arbitrum/nitro-contracts": set this to true or false + keccak: set this to true or false + secp256k1: set this to true or false autoInstallPeers: false +blockExoticSubdeps: false +patchedDependencies: + collateral-margin-abi: patches/collateral-margin-abi@.patch + derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch + futures-marketplace-abi: patches/futures-marketplace-abi@.patch diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts index 15d95d4..684f942 100644 --- a/keeper/scripts/audit-indexer-sync.ts +++ b/keeper/scripts/audit-indexer-sync.ts @@ -7,14 +7,19 @@ */ import { createPublicClient, http, type Address, type Hex } from "viem"; import { baseSepolia } from "viem/chains"; -import { FuturesAbi } from "futures-marketplace/Futures.ts"; +import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; -const ENDPOINT = "https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-futures/dev-latest/gn"; +const ENDPOINT = + "https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-futures/dev-latest/gn"; const USER = "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4".toLowerCase(); -const FUT = (process.env.FUTURES_ADDRESS ?? "0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479") as Address; -const RPC = process.env.ETH_NODE_ADDRESS ?? `https://base-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`; +const FUT = (process.env.FUTURES_ADDRESS ?? + "0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479") as Address; +const RPC = + process.env.ETH_NODE_ADDRESS ?? + `https://base-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`; -if (!RPC) throw new Error("Need RPC URL via ETH_NODE_ADDRESS or ALCHEMY_API_KEY"); +if (!RPC) + throw new Error("Need RPC URL via ETH_NODE_ADDRESS or ALCHEMY_API_KEY"); const client = createPublicClient({ chain: baseSepolia, transport: http(RPC) }); @@ -88,10 +93,28 @@ async function main() { const trades = await fetchTrades(); console.log(`Found ${trades.length} trades\n`); - console.log("%-12s %-10s %-10s %-10s %-10s %s", "block", "indexer", "chainLen", "match", "tx", "status"); - console.log("%-12s %-10s %-10s %-10s %-10s %s", "-----", "-------", "--------", "-----", "--", "------"); - - let firstMismatch: { block: number; indexer: number; chain: number; tx: string } | undefined; + console.log( + "%-12s %-10s %-10s %-10s %-10s %s", + "block", + "indexer", + "chainLen", + "match", + "tx", + "status", + ); + console.log( + "%-12s %-10s %-10s %-10s %-10s %s", + "-----", + "-------", + "--------", + "-----", + "--", + "------", + ); + + let firstMismatch: + | { block: number; indexer: number; chain: number; tx: string } + | undefined; for (const t of trades) { const block = parseInt(t.blockNumber, 10); @@ -104,17 +127,38 @@ async function main() { try { chainLen = await getChainPositionCount(block); } catch (err) { - console.log("%-12s %-10s %-10s %-10s %-10s %s", block, indexerAbs, "ERR", "-", t.transactionHash.slice(0, 10), "rpc-error"); + console.log( + "%-12s %-10s %-10s %-10s %-10s %s", + block, + indexerAbs, + "ERR", + "-", + t.transactionHash.slice(0, 10), + "rpc-error", + ); continue; } const match = indexerAbs === chainLen ? "✓" : "✗ MISMATCH"; const status = indexerAbs === chainLen ? "ok" : "MISMATCH"; - console.log("%-12d %-10d %-10d %-10s %-10s %s", block, indexerAbs, chainLen, indexerAbs === chainLen ? "yes" : "NO", t.transactionHash.slice(0, 10) + "...", status); + console.log( + "%-12d %-10d %-10d %-10s %-10s %s", + block, + indexerAbs, + chainLen, + indexerAbs === chainLen ? "yes" : "NO", + t.transactionHash.slice(0, 10) + "...", + status, + ); if (indexerAbs !== chainLen && !firstMismatch) { - firstMismatch = { block, indexer: indexerAbs, chain: chainLen, tx: t.transactionHash }; + firstMismatch = { + block, + indexer: indexerAbs, + chain: chainLen, + tx: t.transactionHash, + }; } } diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index 0b5c3f6..8600441 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -10,7 +10,7 @@ */ import { createPublicClient, http, type Address, type Hex } from "viem"; import { baseSepolia, base, hardhat } from "viem/chains"; -import { FuturesAbi } from "futures-marketplace/Futures.ts"; +import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; const FUTURES = process.env.FUTURES_ADDRESS as Address; const NETWORK = process.env.NETWORK ?? "base-sepolia"; @@ -25,9 +25,7 @@ const chain = CHAINS[NETWORK as keyof typeof CHAINS]; // Hard-coded list mirroring the production tracker.list() output. // Edit if you want to test different users. -const USERS: Address[] = [ - "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4", -]; +const USERS: Address[] = ["0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4"]; const client = createPublicClient({ chain, transport: http(RPC_URL) }); @@ -81,7 +79,13 @@ const deliveryDurationDays = (await client.readContract({ functionName: "deliveryDurationDays", })) as number; const window = BigInt(deliveryDurationDays) * 86_400n; -console.log("deliveryDurationDays:", deliveryDurationDays, "→ window:", window, "s"); +console.log( + "deliveryDurationDays:", + deliveryDurationDays, + "→ window:", + window, + "s", +); let live = 0; let pastDue = 0; @@ -109,4 +113,6 @@ for (let i = 0; i < allIds.length; i++) { }`, ); } -console.log(`\nsummary: ${live} live, ${pastDue} past-due (settleable), ${expired} expired-window`); +console.log( + `\nsummary: ${live} live, ${pastDue} past-due (settleable), ${expired} expired-window`, +); diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index 792714c..fdf8aa6 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -9,7 +9,7 @@ import { } from "viem"; import { withUnstickRetry } from "../tx/unstick.ts"; import type pino from "pino"; -import { FuturesAbi } from "futures-marketplace/Futures.ts"; +import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index 60602cf..67b862e 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -8,9 +8,9 @@ import { import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; -import { CollateralVaultAbi as collateralVaultAbi } from "collateral-margin/CollateralVault.ts"; -import { HashPowerPerpsDEXAbi as perpsAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; -import { FuturesAbi as futuresAbi } from "futures-marketplace/Futures.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"; /** * 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 8c714d8..3609b7e 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,5 +1,5 @@ import pino from "pino"; -import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; +import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; import { serializeError } from "../../market-maker/src/core/errSerializer.ts"; import { loadConfig } from "./config.ts"; import { createChain } from "./chain.ts"; diff --git a/keeper/src/pme/health.ts b/keeper/src/pme/health.ts index e2e0b58..1ac787c 100644 --- a/keeper/src/pme/health.ts +++ b/keeper/src/pme/health.ts @@ -1,8 +1,8 @@ import type { Address } from "viem"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; -import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; -import { PortfolioMarginEngineAbi } from "collateral-margin/PortfolioMarginEngine.ts"; +import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; +import { PortfolioMarginEngineAbi } from "collateral-margin-abi/PortfolioMarginEngine.ts"; /** * Snapshot of an account's portfolio-margin state at a single block. @@ -94,7 +94,10 @@ export async function readAccountHealthBatch( * (warn / critical thresholds are configured as JS numbers in `Config`); the * MM predicate itself stays in BigInt land via `mmSurplus`. */ -export function computeUtilization(imRequired: bigint, balance: bigint): number { +export function computeUtilization( + imRequired: bigint, + balance: bigint, +): number { if (balance === 0n) { return imRequired === 0n ? 0 : Number.POSITIVE_INFINITY; } diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 6488353..889906d 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -1,10 +1,10 @@ import type { Address, Hex } from "viem"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; -import { CollateralVaultAbi } from "collateral-margin/CollateralVault.ts"; -import { PortfolioMarginEngineAbi } from "collateral-margin/PortfolioMarginEngine.ts"; -import { HashPowerPerpsDEXAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; -import { FuturesAbi } from "futures-marketplace/Futures.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 type { AccountSnapshot, MMParams } from "./types.ts"; /** @@ -13,7 +13,10 @@ import type { AccountSnapshot, MMParams } from "./types.ts"; * of the process — there's no periodic re-read; an admin `setShocks` requires * a keeper restart to pick up. */ -export async function readMMParams(chain: Chain, config: Config): Promise { +export async function readMMParams( + chain: Chain, + config: Config, +): Promise { const reads = await chain.publicClient.multicall({ contracts: [ { diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts index 9840e4a..03b31e9 100644 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -6,7 +6,7 @@ import { type Hex, } from "viem"; import type pino from "pino"; -import { FuturesAbi } from "futures-marketplace/Futures.ts"; +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"; @@ -111,7 +111,10 @@ export class OutdatedOrderSweeper { const expired = await this.discoverExpired(users, blockTimestamp); if (expired.length === 0) { - this.logger.debug({ tracked: users.length }, "sweep clean — no expired orders"); + this.logger.debug( + { tracked: users.length }, + "sweep clean — no expired orders", + ); return 0; } @@ -172,7 +175,9 @@ export class OutdatedOrderSweeper { private async readBlockTimestamp(): Promise { try { - const block = await this.chain.publicClient.getBlock({ blockTag: "latest" }); + const block = await this.chain.publicClient.getBlock({ + blockTag: "latest", + }); return block.timestamp; } catch (err) { this.logger.warn({ err }, "getBlock(latest) failed — skipping sweep"); @@ -257,7 +262,9 @@ export class OutdatedOrderSweeper { * caught and logged so the next sweep retries. */ private async closeBatch(batch: readonly ExpiredOrder[]): Promise { - type SimParams = Parameters[0]; + type SimParams = Parameters< + typeof this.chain.publicClient.simulateContract + >[0]; const simResults = await Promise.allSettled( batch.map((entry) => @@ -311,7 +318,9 @@ export class OutdatedOrderSweeper { }), ); - type WriteParams = Parameters[0]; + type WriteParams = Parameters< + typeof this.chain.walletClient.writeContract + >[0]; let hash: Hex; try { // Same wallet that liquidates / settles — if a previous run left a diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 2b725a3..93d5b5b 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -2,7 +2,7 @@ import { getAddress, pad, toHex, 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/Futures.ts"; +import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index 20128a5..c88b31f 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -11,7 +11,7 @@ import { import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; -import { HashPowerPerpsDEXAbi } from "derivatives-marketplace/HashPowerPerpsDEX.ts"; +import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import { formatGasCost } from "../tx/gasCost.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; @@ -37,7 +37,12 @@ export class PerpsVenue implements Venue { private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; - constructor(chain: Chain, config: Config, logger: pino.Logger, ethUsdFeed?: EthUsdFeed) { + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + ethUsdFeed?: EthUsdFeed, + ) { this.chain = chain; this.config = config; this.logger = logger.child({ venue: "perps" }); @@ -94,7 +99,14 @@ export class PerpsVenue implements Venue { const notional = (marketPrice * absQty) / QUANTITY_SCALE; this.logger.debug( - { user, isLong, qty: position.netQuantity, marketPrice, unrealizedLoss, notional }, + { + user, + isLong, + qty: position.netQuantity, + marketPrice, + unrealizedLoss, + notional, + }, "perps position read", ); @@ -129,7 +141,10 @@ export class PerpsVenue implements Venue { * empty-revert sub-call (typically OOG) — we let that bubble up so * the executor re-queues. */ - async liquidateOrders(user: Address, ids?: readonly Hex[]): Promise { + async liquidateOrders( + user: Address, + ids?: readonly Hex[], + ): Promise { let targetIds = ids; if (targetIds === undefined) { const fetched = (await this.chain.publicClient.readContract({ @@ -166,7 +181,9 @@ export class PerpsVenue implements Venue { args: [calls], account: this.chain.account, }); - const successes = (sim.result as readonly [readonly boolean[], readonly Hex[]])[0]; + const successes = ( + sim.result as readonly [readonly boolean[], readonly Hex[]] + )[0]; if (successes[0] === false) { this.logger.debug( { user, ordersTargeted: targetIds.length }, @@ -191,13 +208,22 @@ export class PerpsVenue implements Venue { const feeEarned = sumOrderLiquidatedFees(receipt); const ordersClosed = countSuccesses(successes); this.logger.info( - { user, hash, ordersClosed, feeEarned, ...formatGasCost(receipt, this.ethUsdFeed) }, + { + user, + hash, + ordersClosed, + feeEarned, + ...formatGasCost(receipt, this.ethUsdFeed), + }, "perps batch liquidate confirmed", ); return { feeEarned }; } - async liquidatePosition(user: Address, _id: Hex): Promise { + async liquidatePosition( + user: Address, + _id: Hex, + ): Promise { const result = await sendLiquidate({ chain: this.chain, config: this.config, @@ -217,7 +243,9 @@ export class PerpsVenue implements Venue { ethUsdFeed: this.ethUsdFeed, }); - return "skipped" in result ? { skipped: result.skipped } : { feeEarned: result.feeEarned }; + return "skipped" in result + ? { skipped: result.skipped } + : { feeEarned: result.feeEarned }; } } diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index aca1639..ea4e6c0 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -8,7 +8,7 @@ import { type Hex, } from "viem"; import pino from "pino"; -import { FuturesAbi } from "futures-marketplace/Futures.ts"; +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"; @@ -28,12 +28,15 @@ interface LogCall { function makeRecordingLogger(): { logger: pino.Logger; calls: LogCall[] } { const calls: LogCall[] = []; const record = - (level: LogCall["level"]) => - (ctxOrMsg: unknown, msg?: string) => { + (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 }); + calls.push({ + level, + msg: msg ?? "", + ctx: ctxOrMsg as Record, + }); } }; const logger = { @@ -78,7 +81,13 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { const publicClient = { getBlock: async () => ({ timestamp: opts.blockTimestamp }), - readContract: async ({ functionName, args }: { functionName: string; args: unknown[] }) => { + readContract: async ({ + functionName, + args, + }: { + functionName: string; + args: unknown[]; + }) => { recorded.readContractCalls++; if (functionName !== "getOrderIds") { throw new Error(`unexpected readContract: ${functionName}`); @@ -86,7 +95,11 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { const user = args[0] as Address; return opts.orderIdsByUser.get(user) ?? []; }, - multicall: async ({ contracts }: { contracts: Array<{ functionName: string; args: unknown[] }> }) => { + multicall: async ({ + contracts, + }: { + contracts: Array<{ functionName: string; args: unknown[] }>; + }) => { recorded.multicallReadCalls++; return contracts.map((c) => { if (c.functionName !== "getOrderById") { @@ -199,7 +212,12 @@ describe("OutdatedOrderSweeper", () => { orderIdsByUser: new Map(), orders: new Map(), }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([]), logger); + const sweeper = new OutdatedOrderSweeper( + chain, + makeConfig(), + makeTracker([]), + logger, + ); const closed = await sweeper.runSweep(); assert.equal(closed, 0); assert.equal(recorded.writeCalls.length, 0); @@ -214,7 +232,12 @@ describe("OutdatedOrderSweeper", () => { orderIdsByUser: new Map([[USER_A, []]]), orders: new Map(), }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + 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); @@ -223,7 +246,7 @@ describe("OutdatedOrderSweeper", () => { it("ignores orders whose deliveryAt is still in the future", async () => { const { logger } = makeRecordingLogger(); - const orderId = "0x" + "11".repeat(32) as Hex; + const orderId = ("0x" + "11".repeat(32)) as Hex; const { chain, recorded } = makeChain({ blockTimestamp: 1_000n, orderIdsByUser: new Map([[USER_A, [orderId]]]), @@ -231,7 +254,12 @@ describe("OutdatedOrderSweeper", () => { [orderId, { participant: USER_A, deliveryAt: 5_000n }], // future ]), }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + 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); @@ -240,9 +268,9 @@ describe("OutdatedOrderSweeper", () => { 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 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]]]), @@ -253,7 +281,12 @@ describe("OutdatedOrderSweeper", () => { ]), }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + const sweeper = new OutdatedOrderSweeper( + chain, + makeConfig(), + makeTracker([USER_A]), + logger, + ); const closed = await sweeper.runSweep(); assert.equal(closed, 2); @@ -278,8 +311,8 @@ describe("OutdatedOrderSweeper", () => { 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 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([ @@ -299,7 +332,11 @@ describe("OutdatedOrderSweeper", () => { ); 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.length, + 1, + "one multicall write for cross-user batch", + ); assert.equal(recorded.writeCalls[0]!.calldatas.length, 2); }); @@ -339,9 +376,9 @@ describe("OutdatedOrderSweeper", () => { // deliveryAt 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 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]]]), @@ -356,21 +393,28 @@ describe("OutdatedOrderSweeper", () => { return undefined; }, }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + 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, + 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 id1 = ("0x" + "11".repeat(32)) as Hex; const { chain, recorded } = makeChain({ blockTimestamp: 10_000n, orderIdsByUser: new Map([[USER_A, [id1]]]), @@ -378,18 +422,27 @@ describe("OutdatedOrderSweeper", () => { }); const config = makeConfig(); (config as { keeper: { dryRun: boolean } }).keeper.dryRun = true; - const sweeper = new OutdatedOrderSweeper(chain, config, makeTracker([USER_A]), logger); + 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.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 getOrderIds 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 idB = ("0x" + "bb".repeat(32)) as Hex; const orderIdsByUser = new Map([[USER_B, [idB]]]); const orders = new Map([ [idB, { participant: USER_B, deliveryAt: 1n }], @@ -411,7 +464,11 @@ describe("OutdatedOrderSweeper", () => { if (user === USER_A) throw new Error("rpc 503"); return orderIdsByUser.get(user) ?? []; }, - multicall: async ({ contracts }: { contracts: Array<{ args: unknown[] }> }) => { + multicall: async ({ + contracts, + }: { + contracts: Array<{ args: unknown[] }>; + }) => { recorded.multicallReadCalls++; return contracts.map((c) => { const order = orders.get(c.args[0] as Hex); @@ -430,12 +487,19 @@ describe("OutdatedOrderSweeper", () => { recorded.simulateCalls.push(args[0] as Hex); return { request: {} }; }, - waitForTransactionReceipt: async () => ({ blockNumber: 1n, gasUsed: 0n, logs: [] }), + 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[] }); + recorded.writeCalls.push({ + functionName: "multicall", + calldatas: args[0] as Hex[], + }); return "0xabc" as Hex; }, }; @@ -452,9 +516,15 @@ describe("OutdatedOrderSweeper", () => { logger, ); const closed = await sweeper.runSweep(); - assert.equal(closed, 1, "USER_B's order still gets closed despite USER_A's RPC failure"); + 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("getOrderIds failed")), + calls.some( + (c) => c.level === "warn" && c.msg.includes("getOrderIds failed"), + ), "expected a warn log for the failed user", ); }); @@ -470,10 +540,17 @@ describe("OutdatedOrderSweeper", () => { walletClient: {}, account: { address: SIGNER }, } as unknown as Chain; - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + 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"))); + assert.ok( + calls.some((c) => c.level === "warn" && c.msg.includes("getBlock")), + ); }); it("coalesces overlapping sweeps — second concurrent runSweep is dropped", async () => { @@ -481,7 +558,7 @@ describe("OutdatedOrderSweeper", () => { // 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; + const id1 = ("0x" + "11".repeat(32)) as Hex; let releaseFirstSweep: () => void = () => undefined; const firstSweepBlocked = new Promise((resolve) => { releaseFirstSweep = resolve; @@ -499,15 +576,28 @@ describe("OutdatedOrderSweeper", () => { walletClient: {}, account: { address: SIGNER }, } as unknown as Chain; - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([USER_A]), logger); + 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"); + 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"); + assert.equal( + getBlockCount, + 1, + "block timestamp read once — second sweep was dropped", + ); }); it("stop() is idempotent and clears the interval", () => { @@ -517,7 +607,12 @@ describe("OutdatedOrderSweeper", () => { orderIdsByUser: new Map(), orders: new Map(), }); - const sweeper = new OutdatedOrderSweeper(chain, makeConfig(), makeTracker([]), logger); + const sweeper = new OutdatedOrderSweeper( + chain, + makeConfig(), + makeTracker([]), + logger, + ); sweeper.stop(); // no-op pre-start sweeper.stop(); // no-op repeated }); From b24d4d7f32e40a819eaa355a85b725c228f4e5d6 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 18:21:11 +0500 Subject: [PATCH 033/180] Remove pnpm version pin in CI workflow --- .github/workflows/keeper-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index e1be7ed..7a067a9 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -34,8 +34,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: "11" - name: Setup Node.js uses: actions/setup-node@v4 From 21007d4d76aafa5582a59433781a800f2533186e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 18:40:29 +0500 Subject: [PATCH 034/180] trigger build --- keeper/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 4093330..62011cd 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -31,6 +31,5 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" }, - "packageManager": "pnpm@11.1.1", - "pnpm": {} + "packageManager": "pnpm@11.1.1" } From 23314ee7603babee09f0380ccd42674a5f8cb33a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 18:48:58 +0500 Subject: [PATCH 035/180] fix(ci): remove invalid working-directory from checkout step, scope pnpm version to keeper --- .github/workflows/keeper-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 7a067a9..f86d003 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -34,6 +34,8 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 + with: + package_json_file: keeper/package.json - name: Setup Node.js uses: actions/setup-node@v4 From 0c56f878a61178c9814b60e7b4a973f5aac8b4a4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 18:57:59 +0500 Subject: [PATCH 036/180] fix: regen lockfile --- keeper/pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index b062f54..14d1847 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -151,7 +151,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-Bfl5FyD8XUbzr0oLvEPR9DsygBNN6eoI2oPVaJP51r7l1KNT6ZoeO8c8QOud8/58R2AgMR94qU+s+47SlDJIOQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487} version: 0.0.0 end-of-stream@1.4.5: From 78a89bb22b46608ceb1243fac21d3ecd6234c2b8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 19:59:38 +0500 Subject: [PATCH 037/180] ci: integration test setup --- .github/workflows/keeper-test.yml | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index f86d003..a67fdc9 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -38,7 +38,7 @@ jobs: package_json_file: keeper/package.json - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" @@ -56,6 +56,39 @@ jobs: working-directory: ./keeper run: pnpm test + - name: Checkout perps + uses: actions/checkout@v6 + with: + repository: Lumerin-protocol/derivatives-marketplace + ref: dev + path: perps + + - name: Checkout futures-marketplace + uses: actions/checkout@v6 + with: + repository: Lumerin-protocol/futures-marketplace + ref: dev + path: futures-marketplace + + - name: Install contracts dependencies + working-directory: ./contracts + run: pnpm install --frozen-lockfile + + - name: Install perps contracts dependencies + working-directory: ./perps/contracts + run: pnpm install --frozen-lockfile + + - name: Install futures contracts dependencies + working-directory: ./futures-marketplace/contracts + run: pnpm install --frozen-lockfile + + - name: Compile siblings + working-directory: ./keeper + run: pnpm pretest:integration + env: + PERPS_REPO: ${{ github.workspace }}/perps/contracts + FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts + - name: Integration tests working-directory: ./keeper run: pnpm test:integration From 9b4b184dcd52e4fea2dda7cb1104f8e058b45fb4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:06:01 +0500 Subject: [PATCH 038/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index a67fdc9..5c95549 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -82,13 +82,9 @@ jobs: working-directory: ./futures-marketplace/contracts run: pnpm install --frozen-lockfile - - name: Compile siblings + - name: Integration tests working-directory: ./keeper - run: pnpm pretest:integration + run: pnpm test:integration env: PERPS_REPO: ${{ github.workspace }}/perps/contracts FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts - - - name: Integration tests - working-directory: ./keeper - run: pnpm test:integration From 12fdd004d41f27b2013ec279625b009caabd27b7 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:17:03 +0500 Subject: [PATCH 039/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 43 ++++++++++++------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 5c95549..909a1af 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -29,49 +29,37 @@ jobs: timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout collateral-margin + uses: actions/checkout@v6 + with: + path: collateral-margin - name: Install pnpm uses: pnpm/action-setup@v4 with: - package_json_file: keeper/package.json + package_json_file: collateral-margin/keeper/package.json - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" - cache-dependency-path: keeper/pnpm-lock.yaml + cache-dependency-path: collateral-margin/keeper/pnpm-lock.yaml - - name: Install dependencies - working-directory: ./keeper + - name: Install keeper dependencies + working-directory: ./collateral-margin/keeper run: pnpm install --frozen-lockfile - name: TypeCheck - working-directory: ./keeper + working-directory: ./collateral-margin/keeper run: pnpm typecheck - name: Unit tests - working-directory: ./keeper + working-directory: ./collateral-margin/keeper run: pnpm test - - name: Checkout perps - uses: actions/checkout@v6 - with: - repository: Lumerin-protocol/derivatives-marketplace - ref: dev - path: perps - - - name: Checkout futures-marketplace - uses: actions/checkout@v6 - with: - repository: Lumerin-protocol/futures-marketplace - ref: dev - path: futures-marketplace - - name: Install contracts dependencies - working-directory: ./contracts + working-directory: ./collateral-margin/contracts run: pnpm install --frozen-lockfile - name: Install perps contracts dependencies @@ -82,9 +70,10 @@ jobs: working-directory: ./futures-marketplace/contracts run: pnpm install --frozen-lockfile + - name: Compile siblings + working-directory: ./collateral-margin/keeper + run: pnpm pretest:integration + - name: Integration tests - working-directory: ./keeper + working-directory: ./collateral-margin/keeper run: pnpm test:integration - env: - PERPS_REPO: ${{ github.workspace }}/perps/contracts - FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts From ab6bb09c8a9a3781f85f205ec60de8fb9b0128bd Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:20:07 +0500 Subject: [PATCH 040/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 909a1af..cd1a141 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -47,33 +47,33 @@ jobs: cache-dependency-path: collateral-margin/keeper/pnpm-lock.yaml - name: Install keeper dependencies - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm install --frozen-lockfile - name: TypeCheck - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm typecheck - name: Unit tests - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm test - name: Install contracts dependencies - working-directory: ./collateral-margin/contracts + working-directory: collateral-margin/contracts run: pnpm install --frozen-lockfile - name: Install perps contracts dependencies - working-directory: ./perps/contracts + working-directory: perps/contracts run: pnpm install --frozen-lockfile - name: Install futures contracts dependencies - working-directory: ./futures-marketplace/contracts + working-directory: futures-marketplace/contracts run: pnpm install --frozen-lockfile - name: Compile siblings - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm pretest:integration - name: Integration tests - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm test:integration From b7508c61b73a759efa11c3b40f4b11840c13e309 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:26:31 +0500 Subject: [PATCH 041/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index cd1a141..ce5ef83 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -47,33 +47,48 @@ jobs: cache-dependency-path: collateral-margin/keeper/pnpm-lock.yaml - name: Install keeper dependencies - working-directory: collateral-margin/keeper + working-directory: ./collateral-margin/keeper run: pnpm install --frozen-lockfile - name: TypeCheck - working-directory: collateral-margin/keeper + working-directory: ./collateral-margin/keeper run: pnpm typecheck - name: Unit tests - working-directory: collateral-margin/keeper + working-directory: ./collateral-margin/keeper run: pnpm test - name: Install contracts dependencies - working-directory: collateral-margin/contracts + working-directory: ./collateral-margin/contracts run: pnpm install --frozen-lockfile + - name: Checkout perps + uses: actions/checkout@v6 + with: + repository: Lumerin-protocol/derivatives-marketplace + ref: dev + + - name: Checkout futures-marketplace + uses: actions/checkout@v6 + with: + repository: Lumerin-protocol/futures-marketplace + ref: dev + - name: Install perps contracts dependencies - working-directory: perps/contracts + working-directory: ./perps/contracts run: pnpm install --frozen-lockfile - name: Install futures contracts dependencies - working-directory: futures-marketplace/contracts + working-directory: ./futures-marketplace/contracts run: pnpm install --frozen-lockfile + - name: List folders + run: find . -maxdepth 3 -type d | sort | head -50 + - name: Compile siblings - working-directory: collateral-margin/keeper + working-directory: ./collateral-margin/keeper run: pnpm pretest:integration - name: Integration tests - working-directory: collateral-margin/keeper + working-directory: ./collateral-margin/keeper run: pnpm test:integration From e517592c542c8f727fb96e8af21a703d3b9c3276 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:30:17 +0500 Subject: [PATCH 042/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index ce5ef83..f9687b4 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -47,19 +47,19 @@ jobs: cache-dependency-path: collateral-margin/keeper/pnpm-lock.yaml - name: Install keeper dependencies - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm install --frozen-lockfile - name: TypeCheck - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm typecheck - name: Unit tests - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm test - name: Install contracts dependencies - working-directory: ./collateral-margin/contracts + working-directory: collateral-margin/contracts run: pnpm install --frozen-lockfile - name: Checkout perps @@ -67,6 +67,7 @@ jobs: with: repository: Lumerin-protocol/derivatives-marketplace ref: dev + path: perps - name: Checkout futures-marketplace uses: actions/checkout@v6 @@ -75,20 +76,20 @@ jobs: ref: dev - name: Install perps contracts dependencies - working-directory: ./perps/contracts + working-directory: perps/contracts run: pnpm install --frozen-lockfile - name: Install futures contracts dependencies - working-directory: ./futures-marketplace/contracts + working-directory: futures-marketplace/contracts run: pnpm install --frozen-lockfile - name: List folders run: find . -maxdepth 3 -type d | sort | head -50 - name: Compile siblings - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm pretest:integration - name: Integration tests - working-directory: ./collateral-margin/keeper + working-directory: collateral-margin/keeper run: pnpm test:integration From 43f762c05910b3599a43b9c865a5d2558ad76d8d Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 20:43:45 +0500 Subject: [PATCH 043/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index f9687b4..86a014a 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -75,6 +75,9 @@ jobs: repository: Lumerin-protocol/futures-marketplace ref: dev + - name: List folders + run: find . -maxdepth 3 -type d | sort | head -50 + - name: Install perps contracts dependencies working-directory: perps/contracts run: pnpm install --frozen-lockfile @@ -83,9 +86,6 @@ jobs: working-directory: futures-marketplace/contracts run: pnpm install --frozen-lockfile - - name: List folders - run: find . -maxdepth 3 -type d | sort | head -50 - - name: Compile siblings working-directory: collateral-margin/keeper run: pnpm pretest:integration From 617f58400665774d31204927ce4ed678022004ce Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 21:02:07 +0500 Subject: [PATCH 044/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 86a014a..94ee606 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -76,7 +76,8 @@ jobs: ref: dev - name: List folders - run: find . -maxdepth 3 -type d | sort | head -50 + working-directory: ../ + run: find . -maxdepth 4 -type d | sort - name: Install perps contracts dependencies working-directory: perps/contracts From 895915694cb80770082c9bc1f31c7355a9ca18f2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 21:05:12 +0500 Subject: [PATCH 045/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 94ee606..52d1f79 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -74,10 +74,11 @@ jobs: with: repository: Lumerin-protocol/futures-marketplace ref: dev + path: futures - name: List folders - working-directory: ../ - run: find . -maxdepth 4 -type d | sort + working-directory: ../../ + run: find . -maxdepth 5 -type d | sort - name: Install perps contracts dependencies working-directory: perps/contracts From 1ea1f35584227853d5afa03210985543147958dc Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 21:40:42 +0500 Subject: [PATCH 046/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 52d1f79..b428941 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -74,7 +74,6 @@ jobs: with: repository: Lumerin-protocol/futures-marketplace ref: dev - path: futures - name: List folders working-directory: ../../ From 78ef51f77d316b87696fa46d657ad9db86aebcd3 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 21:47:10 +0500 Subject: [PATCH 047/180] Update keeper-test.yml --- .github/workflows/keeper-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index b428941..2c90c1c 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -74,6 +74,7 @@ jobs: with: repository: Lumerin-protocol/futures-marketplace ref: dev + path: futures-marketplace - name: List folders working-directory: ../../ From 119b5f82425b2e79f6e0f8c30a9cfa487c729966 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 22:40:01 +0500 Subject: [PATCH 048/180] fix(market-maker): requote gatekeeper misses stale orders at wrong prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in OrderExecutor.shouldRequote() caused the orderbook to stay permanently shifted when resting orders sat at wrong prices: 1. hasQuantityDeficit checked 'have !== undefined' before comparing sizes, so completely missing price levels were never flagged as a deficit. 2. No stale-orders guard existed — when stale orders at wrong prices coexisted with correct ones (e.g. cancels failed, creates succeeded), the deficit check passed (all desired levels filled) and the stale orders persisted forever. Fixes: - hasQuantityDeficit: 'have !== undefined' → 'have === undefined || ...' - New hasStaleOrders guard in shouldRequote - shouldRequote now logs the exact reason for every skip/trigger Tests: 3 regression cases (all-orders-wrong, mixed stale+correct, match) --- market-maker/src/core/orderExecutor.ts | 70 +++++- market-maker/tests/core/orderExecutor.test.ts | 208 ++++++++++++++++++ 2 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 market-maker/tests/core/orderExecutor.test.ts diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 74eba89..40f88f7 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -72,7 +72,6 @@ export class OrderExecutor { async reconcile(desired: OrderIntent[]): Promise { if (!this.shouldRequote(desired)) { - this.logger.debug("requote skipped (within threshold or cooldown)"); return; } @@ -164,15 +163,71 @@ export class OrderExecutor { this.stats.ordersCancelled += orders.length; } + /** + * Decide whether to run a reconciliation (cancel stale + place missing). + * Logs the reason at debug level so operators can diagnose stale orderbooks. + */ private shouldRequote(desired: OrderIntent[]): boolean { - if (Date.now() - this.lastRequoteAt < this.effectiveCooldownMs()) + const elapsed = Date.now() - this.lastRequoteAt; + const cooldownMs = this.effectiveCooldownMs(); + if (elapsed < cooldownMs) { + this.logger.debug( + { elapsedMs: elapsed, cooldownMs }, + "requote skipped: cooldown", + ); return false; + } const expectedCount = desired.length; - if (this.book.ownOrders.size < expectedCount) return true; - if (this.hasQuantityDeficit(desired)) return true; + const actualCount = this.book.ownOrders.size; + if (actualCount < expectedCount) { + this.logger.debug( + { actualCount, expectedCount }, + "requote triggered: order count deficit", + ); + return true; + } + + if (this.hasQuantityDeficit(desired)) { + this.logger.debug("requote triggered: quantity deficit"); + return true; + } + + if (this.hasStaleOrders(desired)) { + this.logger.debug("requote triggered: stale orders at wrong prices"); + return true; + } + + const drift = this.priceDriftTicks(); + const threshold = this.effectiveRequoteThreshold(); + if (drift >= threshold) { + this.logger.debug({ drift, threshold }, "requote triggered: price drift"); + return true; + } + + this.logger.debug( + { drift, threshold, actualCount, expectedCount }, + "requote skipped: no deficit / no stale / no drift", + ); + return false; + } - return this.priceDriftTicks() >= this.effectiveRequoteThreshold(); + /** True when any own order sits at a price not in the desired set. */ + private hasStaleOrders(desired: OrderIntent[]): boolean { + const desiredPrices = new Map>(); + for (const i of desired) { + let set = desiredPrices.get(i.side); + if (!set) { + set = new Set(); + desiredPrices.set(i.side, set); + } + set.add(i.price); + } + for (const order of this.book.ownOrders.values()) { + const set = desiredPrices.get(order.side); + if (!set || !set.has(order.price)) return true; + } + return false; } private priceDriftTicks(): number { @@ -271,7 +326,10 @@ export class OrderExecutor { const existing = this.aggregateOwnSizeByPriceSide(); for (const i of desired) { const have = existing.get(keyOf(i.side, i.price)); - if (have !== undefined && i.size - have > 0n) return true; + // Deficit means: no orders at this price at all, OR fewer than desired. + // The `undefined` branch catches stale orders at wrong prices that the + // other guards (count, price-drift) would also miss. + if (have === undefined || i.size - have > 0n) return true; } return false; } diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts new file mode 100644 index 0000000..5fcc882 --- /dev/null +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -0,0 +1,208 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { OrderExecutor, type OrderExecutorConfig } from "../../src/core/orderExecutor.ts"; +import type { InstrumentAdapter, OrderIntent, OwnOrder, MatchingMode } from "../../src/core/adapter.ts"; +import type { Quoter } from "../../src/core/quoter.ts"; +import type { BookTracker } from "../../src/bookTracker.ts"; +import type { GasTracker } from "../../src/gasTracker.ts"; +import type { RiskManager } from "../../src/riskManager.ts"; +import type { OracleTracker } from "../../src/oracleTracker.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; +} + +function makeOrderId(n: number): `0x${string}` { + return `0x${n.toString(16).padStart(64, "0")}` as `0x${string}`; +} + +function makeConfig(overrides: Partial = {}): OrderExecutorConfig { + return { + requoteCooldownMs: 0, + requoteThresholdTicks: 2, + urgentRequoteThresholdTicks: 10, + dryRun: false, + ...overrides, + }; +} + +interface TestDeps { + instrument: InstrumentAdapter; + quoter: Quoter; + book: BookTracker; + gas: GasTracker; + risk: RiskManager; + oracle: OracleTracker; + cancelledOrderIds: `0x${string}`[]; + placedIntents: OrderIntent[]; +} + +function makeDeps(overrides: Partial = {}): TestDeps { + const cancelledOrderIds: `0x${string}`[] = []; + const placedIntents: OrderIntent[] = []; + + const deps: TestDeps = { + instrument: { + id: "test-instrument", + book: { matchingMode: "exact" as MatchingMode }, + executeOrders: async (intent) => { + for (const c of intent.cancels) cancelledOrderIds.push(c.orderId); + for (const p of intent.creates) placedIntents.push(p); + return { receipts: [{ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }], errors: [] }; + }, + } as unknown as InstrumentAdapter, + quoter: { + getTick: () => 10_000n, + } as unknown as Quoter, + book: { + ownOrders: new Map<`0x${string}`, OwnOrder>(), + } as unknown as BookTracker, + gas: { + isGasSpiking: false, + gasSpikePct: 0, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as GasTracker, + risk: { + throttled: false, + recordGasCost: noop, + canPlaceOrders: async () => true, + } as unknown as RiskManager, + oracle: { + currentPrice: 100_000_000n, + } as unknown as OracleTracker, + cancelledOrderIds, + placedIntents, + ...overrides, + }; + return deps; +} + +function makeExecutor(deps: TestDeps): OrderExecutor { + return new OrderExecutor( + deps.instrument, + makeConfig(), + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); +} + +function desiredBuy(price: bigint, size = 1_000_000n): OrderIntent { + return { side: "buy", price, size }; +} +function desiredSell(price: bigint, size = 1_000_000n): OrderIntent { + return { side: "sell", price, size }; +} + +/** + * Add a fake own order to the book tracker. The `size` here is unsigned + * (matches `OwnOrder.size` from the adapter). + */ +function seedOrder( + book: BookTracker, + id: number, + side: "buy" | "sell", + price: bigint, + size = 1_000_000n, +): void { + book.ownOrders.set(makeOrderId(id), { + orderId: makeOrderId(id), + price, + side, + size, + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("OrderExecutor requote guards (regression)", () => { + /** + * Bug: when all resting orders are at wrong prices (e.g. stale from a + * previous oracle level), `hasQuantityDeficit` returned false because no + * desired level had matching existing orders (`have === undefined`). + * A requote was never triggered and the book stayed shifted forever. + */ + it("requotes when all orders are at wrong prices (quantity-deficit fix)", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + // Seed the book with stale orders at wrong prices (oracle was higher). + seedOrder(deps.book, 1, "buy", 99_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "buy", 98_000_000n, 1_000_000n); + seedOrder(deps.book, 3, "sell", 101_000_000n, 1_000_000n); + seedOrder(deps.book, 4, "sell", 102_000_000n, 1_000_000n); + + // Desired quotes are at the current (lower) oracle prices. + const desired: OrderIntent[] = [ + desiredBuy(95_000_000n), + desiredSell(96_000_000n), + ]; + + await executor.reconcile(desired); + + // All stale orders must be cancelled. + assert.equal(deps.cancelledOrderIds.length, 4, "all stale orders cancelled"); + // Missing desired levels must be placed. + assert.equal(deps.placedIntents.length, 2, "missing levels placed"); + }); + + /** + * Bug: when stale orders at wrong prices coexist with correct orders at + * desired prices (e.g. cancels failed but creates succeeded on a prior + * reconciliation), the deficit check didn't fire (all desired levels + * have sufficient quantity), and the stale-orders guard was missing. + * The wrong-price orders persisted forever. + */ + it("requotes when stale orders coexist with correct ones (stale-orders guard)", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + // Correct orders at the right prices. + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + + // Stale orders at wrong prices (leftover from a previous oracle level + // whose cancels failed or were never submitted). + seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); + seedOrder(deps.book, 4, "sell", 101_000_000n, 1_000_000n); + + const desired: OrderIntent[] = [ + desiredBuy(95_000_000n), + desiredSell(96_000_000n), + ]; + + await executor.reconcile(desired); + + // Stale orders must be cancelled. + assert.equal(deps.cancelledOrderIds.length, 2, "stale orders cancelled"); + // Correct orders must survive (no deficit → no new placement at same prices). + assert.equal(deps.placedIntents.length, 0, "no new orders at already-filled prices"); + }); + + /** + * Sanity: when the book already matches the desired quotes exactly, + * no reconciliation work should happen. + */ + it("skips requote when the book matches desired quotes", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + + const desired: OrderIntent[] = [ + desiredBuy(95_000_000n), + desiredSell(96_000_000n), + ]; + + await executor.reconcile(desired); + + assert.equal(deps.cancelledOrderIds.length, 0, "no unnecessary cancels"); + assert.equal(deps.placedIntents.length, 0, "no unnecessary placements"); + }); +}); From 3e883f71ef159a8a6714f9e493aa8ae18fe17915 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 22:42:02 +0500 Subject: [PATCH 049/180] chore(market-maker): add fetch-logs.sh for CloudWatch log streaming Parameterised by environment (dev|stg|prd). Strips the ECS log prefix and pipes through pino-pretty for human-readable output. --- market-maker/scripts/fetch-logs.sh | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100755 market-maker/scripts/fetch-logs.sh diff --git a/market-maker/scripts/fetch-logs.sh b/market-maker/scripts/fetch-logs.sh new file mode 100755 index 0000000..1892085 --- /dev/null +++ b/market-maker/scripts/fetch-logs.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +# Fetch and pretty-print CloudWatch logs for the futures market maker. +# +# Usage: +# sh scripts/fetch-logs.sh dev # base-sepolia (development) +# sh scripts/fetch-logs.sh stg # base-mainnet (staging) +# sh scripts/fetch-logs.sh prd # base-mainnet (production) +# +# Prerequisites: AWS CLI v2, pnpm, pino-pretty (devDependency). +# AWS credentials are resolved via the named profile (~/.aws/config). + +set -eu + +ENV="${1:-dev}" +REGION="${2:-us-east-1}" + +case "$ENV" in + dev) LOG_GROUP="/ecs/col-mar-futures-mm-dev" ;; + stg) LOG_GROUP="/ecs/col-mar-futures-mm-stg" ;; + prd) LOG_GROUP="/ecs/col-mar-futures-mm-prd" ;; + *) echo "unknown env: $ENV (use dev | stg | prd)" >&2; exit 1 ;; +esac + +AWS_PROFILE="$ENV" aws logs tail "$LOG_GROUP" \ + --region "$REGION" \ + --follow \ + --format short \ + | sed -E 's/^[^ ]+ //' \ + | pnpm pino-pretty From 05385ebf7566692c41266224aebaf8e3479a1d65 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 22:43:09 +0500 Subject: [PATCH 050/180] chore(market-maker): parameterise fetch-logs.sh by venue (futures|perps) and env --- market-maker/scripts/fetch-logs.sh | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/market-maker/scripts/fetch-logs.sh b/market-maker/scripts/fetch-logs.sh index 1892085..a10a614 100755 --- a/market-maker/scripts/fetch-logs.sh +++ b/market-maker/scripts/fetch-logs.sh @@ -1,26 +1,33 @@ #!/usr/bin/env sh -# Fetch and pretty-print CloudWatch logs for the futures market maker. +# Fetch and pretty-print CloudWatch logs for the Titan market maker. # # Usage: -# sh scripts/fetch-logs.sh dev # base-sepolia (development) -# sh scripts/fetch-logs.sh stg # base-mainnet (staging) -# sh scripts/fetch-logs.sh prd # base-mainnet (production) +# sh scripts/fetch-logs.sh futures dev # base-sepolia +# sh scripts/fetch-logs.sh perps dev # base-sepolia +# sh scripts/fetch-logs.sh futures stg # base-mainnet (staging) +# sh scripts/fetch-logs.sh perps prd # base-mainnet (production) # # Prerequisites: AWS CLI v2, pnpm, pino-pretty (devDependency). # AWS credentials are resolved via the named profile (~/.aws/config). set -eu -ENV="${1:-dev}" -REGION="${2:-us-east-1}" +VENUE="${1:?usage: sh scripts/fetch-logs.sh }" +ENV="${2:?usage: sh scripts/fetch-logs.sh }" +REGION="${3:-us-east-1}" + +case "$VENUE" in + futures|perps) ;; + *) echo "unknown venue: $VENUE (use futures | perps)" >&2; exit 1 ;; +esac case "$ENV" in - dev) LOG_GROUP="/ecs/col-mar-futures-mm-dev" ;; - stg) LOG_GROUP="/ecs/col-mar-futures-mm-stg" ;; - prd) LOG_GROUP="/ecs/col-mar-futures-mm-prd" ;; - *) echo "unknown env: $ENV (use dev | stg | prd)" >&2; exit 1 ;; + dev|stg|prd) ;; + *) echo "unknown env: $ENV (use dev | stg | prd)" >&2; exit 1 ;; esac +LOG_GROUP="/ecs/col-mar-${VENUE}-mm-${ENV}" + AWS_PROFILE="$ENV" aws logs tail "$LOG_GROUP" \ --region "$REGION" \ --follow \ From fe7ec9cc071de4874e492fc43493f61aa00bb6cd Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 22:47:50 +0500 Subject: [PATCH 051/180] fix(market-maker): correct import paths and add type annotation in orderExecutor test --- market-maker/tests/core/orderExecutor.test.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 5fcc882..4c67c9f 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -1,23 +1,36 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { OrderExecutor, type OrderExecutorConfig } from "../../src/core/orderExecutor.ts"; -import type { InstrumentAdapter, OrderIntent, OwnOrder, MatchingMode } from "../../src/core/adapter.ts"; +import { + OrderExecutor, + type OrderExecutorConfig, +} from "../../src/core/orderExecutor.ts"; +import type { + InstrumentAdapter, + OrderIntent, + OwnOrder, + MatchingMode, + ExecuteOrdersIntent, +} from "../../src/core/adapter.ts"; import type { Quoter } from "../../src/core/quoter.ts"; -import type { BookTracker } from "../../src/bookTracker.ts"; -import type { GasTracker } from "../../src/gasTracker.ts"; -import type { RiskManager } from "../../src/riskManager.ts"; -import type { OracleTracker } from "../../src/oracleTracker.ts"; +import type { BookTracker } from "../../src/core/bookTracker.ts"; +import type { GasTracker } from "../../src/core/gasTracker.ts"; +import type { RiskManager } from "../../src/core/riskManager.ts"; +import type { OracleTracker } from "../../src/core/oracleTracker.ts"; const noop = () => {}; function makeLogger(): never { - return { child: () => ({ debug: noop, info: noop, warn: noop, error: noop }) } as never; + return { + child: () => ({ debug: noop, info: noop, warn: noop, error: noop }), + } as never; } function makeOrderId(n: number): `0x${string}` { return `0x${n.toString(16).padStart(64, "0")}` as `0x${string}`; } -function makeConfig(overrides: Partial = {}): OrderExecutorConfig { +function makeConfig( + overrides: Partial = {}, +): OrderExecutorConfig { return { requoteCooldownMs: 0, requoteThresholdTicks: 2, @@ -46,10 +59,13 @@ function makeDeps(overrides: Partial = {}): TestDeps { instrument: { id: "test-instrument", book: { matchingMode: "exact" as MatchingMode }, - executeOrders: async (intent) => { + executeOrders: async (intent: ExecuteOrdersIntent) => { for (const c of intent.cancels) cancelledOrderIds.push(c.orderId); for (const p of intent.creates) placedIntents.push(p); - return { receipts: [{ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }], errors: [] }; + return { + receipts: [{ gasUsed: 200_000n, effectiveGasPrice: 1_000_000_000n }], + errors: [], + }; }, } as unknown as InstrumentAdapter, quoter: { @@ -146,7 +162,11 @@ describe("OrderExecutor requote guards (regression)", () => { await executor.reconcile(desired); // All stale orders must be cancelled. - assert.equal(deps.cancelledOrderIds.length, 4, "all stale orders cancelled"); + assert.equal( + deps.cancelledOrderIds.length, + 4, + "all stale orders cancelled", + ); // Missing desired levels must be placed. assert.equal(deps.placedIntents.length, 2, "missing levels placed"); }); @@ -181,7 +201,11 @@ describe("OrderExecutor requote guards (regression)", () => { // Stale orders must be cancelled. assert.equal(deps.cancelledOrderIds.length, 2, "stale orders cancelled"); // Correct orders must survive (no deficit → no new placement at same prices). - assert.equal(deps.placedIntents.length, 0, "no new orders at already-filled prices"); + assert.equal( + deps.placedIntents.length, + 0, + "no new orders at already-filled prices", + ); }); /** From c7b2422d54355bef4d72e3930e70636236b3fce8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 26 May 2026 23:55:29 +0500 Subject: [PATCH 052/180] refactor(market-maker): wire batch sizes into every multicall via config Replace hardcoded WRITE_BATCH_SIZE (futures: 50) and max (perps: 200) with the existing config-driven cancelBatchSize / createBatchSize at the tx chunk level. Cancels and creates are now broadcast in separate multicall txs using their own chunk sizes, keeping the cancel-first ordering. Also drop the unused writeBatchSize fields from config schema and YAMLs. --- market-maker/configs/futures.dev.yml | 5 +- market-maker/configs/futures.local.yml | 5 +- market-maker/configs/futures.prd.yml | 5 +- market-maker/configs/futures.stg.yml | 5 +- market-maker/configs/perps.dev.yml | 5 +- market-maker/configs/perps.local.yml | 5 +- market-maker/configs/perps.prd.yml | 5 +- market-maker/configs/perps.stg.yml | 5 +- market-maker/schemas/futures.json | 22 +------ market-maker/schemas/perps.json | 24 ++------ market-maker/src/adapters/futures/index.ts | 6 +- .../src/adapters/futures/instrument.ts | 60 +++++++++---------- market-maker/src/adapters/futures/venue.ts | 10 +--- market-maker/src/apps/futures/config.ts | 11 +--- market-maker/src/apps/futures/main.ts | 3 +- market-maker/src/apps/perps/config.ts | 17 ++---- market-maker/src/apps/perps/main.ts | 4 +- 17 files changed, 64 insertions(+), 133 deletions(-) diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 48d23e8..2d93e7f 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -88,7 +88,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 3 +readBatchSize: 30 # Per-operation batch sizes for writes (futures: closeOrder limit / createOrders limit). -cancelBatchSize: 20 -createBatchSize: 10 +writeBatchSize: 20 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 6d1d90e..012c4a4 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -75,7 +75,6 @@ oracle: health: port: 3001 -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 20 -createBatchSize: 10 +writeBatchSize: 20 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index 7278fb2..f1c6019 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -81,7 +81,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 20 -createBatchSize: 10 +writeBatchSize: 20 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 281b474..24abac4 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -81,7 +81,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 20 -createBatchSize: 10 +writeBatchSize: 20 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 1b6cd06..4701e76 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -91,7 +91,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3002} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes (perps: individual cancelOrder / createOrder). -cancelBatchSize: 30 -createBatchSize: 30 +writeBatchSize: 20 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index 614f074..e2bf88a 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -81,7 +81,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 30 -createBatchSize: 30 +writeBatchSize: 20 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index 087132b..d34611d 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -83,7 +83,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 30 -createBatchSize: 30 +writeBatchSize: 20 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index 483a726..7b17b9e 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -82,7 +82,6 @@ oracle: health: port: ${MAKER_HEALTH_PORT:-3001} -readBatchSize: 10 +readBatchSize: 100 # Per-operation batch sizes for writes. -cancelBatchSize: 30 -createBatchSize: 30 +writeBatchSize: 20 diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index aa9c03b..1cac2ab 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -22,8 +22,7 @@ "timing", "health", "readBatchSize", - "cancelBatchSize", - "createBatchSize" + "writeBatchSize" ], "properties": { "nodeEnv": { @@ -924,7 +923,7 @@ "description": "Maximum number of contract calls bundled into a single Multicall3 read. Calls are chunked transparently; lower values reduce RPC timeouts on busy providers at the cost of more round-trips.", "default": 10 }, - "cancelBatchSize": { + "writeBatchSize": { "anyOf": [ { "minimum": 1, @@ -936,23 +935,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Maximum closeOrder calls per cancellation batch. The adapter groups cancels into chunks of this size before sending.", + "description": "Maximum qty per write batch. The adapter groups cancels into chunks of this size before sending.", "default": 20 - }, - "createBatchSize": { - "anyOf": [ - { - "minimum": 1, - "type": "number" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Maximum orders per createOrders call. The adapter packs creates into batches of this size.", - "default": 10 } } } diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 9fc3ef9..a074c22 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -22,8 +22,7 @@ "timing", "health", "readBatchSize", - "cancelBatchSize", - "createBatchSize" + "writeBatchSize" ], "properties": { "nodeEnv": { @@ -901,7 +900,7 @@ "description": "Maximum number of contract calls bundled into a single Multicall3 read. Calls are chunked transparently; lower values reduce RPC timeouts.", "default": 10 }, - "cancelBatchSize": { + "writeBatchSize": { "anyOf": [ { "minimum": 1, @@ -913,23 +912,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Maximum cancelOrder calls per cancellation batch. Perps has no batch cancel — each cancel is one call.", - "default": 30 - }, - "createBatchSize": { - "anyOf": [ - { - "minimum": 1, - "type": "number" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Maximum createOrder calls per creation batch. Perps has no batch create — each create is one call.", - "default": 30 + "description": "Maximum qty per write batch. The adapter groups cancels into chunks of this size before sending.", + "default": 20 } } } diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index fe6a272..a65c0a1 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -8,12 +8,8 @@ export interface CreateFuturesVenueOpts { wallet: WalletContext; address: `0x${string}`; multicall3Address?: `0x${string}`; - /** Max calls per Multicall3 read batch. Default 100. */ readBatchSize: number; - /** Max closeOrder calls per cancellation batch. Default 20. */ - cancelBatchSize: number; - /** Max orders per createOrders call. Default 10. */ - createBatchSize: number; + writeBatchSize: number; logger: pino.Logger; } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 919e7e8..0a6a984 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -19,9 +19,6 @@ import { FuturesOwnOrders } from "./ownOrders.ts"; const FUTURES_INSTRUMENT_ID = "futures"; -/** Maximum encoded calls per multicall write tx (safety net for block gas limit). */ -const WRITE_BATCH_SIZE = 50; - export class FuturesInstrumentAdapter implements InstrumentAdapter { readonly id = FUTURES_INSTRUMENT_ID; readonly venue: FuturesVenueAdapter; @@ -34,9 +31,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { this.venue = venue; - const batchSize = venue.readBatchSize; - this.book = new FuturesBook(this, batchSize); - this.ownOrders = new FuturesOwnOrders(venue, logger, batchSize); + this.book = new FuturesBook(this, venue.readBatchSize); + this.ownOrders = new FuturesOwnOrders(venue, logger, venue.readBatchSize); } async getIndexPrice(): Promise { @@ -147,18 +143,12 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { ): Promise { // 1. Build the ordered call list: cancels first, then creates. const calls = this.buildCallList(intent); - if (calls.length === 0) { - return { receipts: [], errors: [] }; - } if (intent.dryRun) { - const totalBatches = Math.ceil(calls.length / WRITE_BATCH_SIZE); logger.info( { cancels: intent.cancels.length, creates: intent.creates.length, - calls: calls.length, - batches: totalBatches, }, "DRY RUN: would send multicall batches", ); @@ -168,12 +158,11 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { // 2. Chunk into tx-sized groups and broadcast sequentially. const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; const errors: Error[] = []; - const totalBatches = Math.ceil(calls.length / WRITE_BATCH_SIZE); - - for (let offset = 0; offset < calls.length; offset += WRITE_BATCH_SIZE) { - const chunk = calls.slice(offset, offset + WRITE_BATCH_SIZE); - const batchNum = Math.floor(offset / WRITE_BATCH_SIZE) + 1; + const totalBatches = calls.length; + console.log(calls); + for (let batchNum = 0; batchNum < totalBatches; batchNum++) { + const chunk = calls[batchNum]; try { const hash = await this.venue.multicall(chunk, { maxFeePerGas: intent.maxFeePerGas, @@ -211,26 +200,33 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** Build the ordered call list: cancels (individual closeOrder) then creates (createOrders). */ - private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[] { - const cancelSize = this.venue.cancelBatchSize; - const createSize = this.venue.createBatchSize; - const calls: `0x${string}`[] = []; - - // Cancels: chunk by cancelBatchSize, each = one closeOrder call. - for (let i = 0; i < intent.cancels.length; i += cancelSize) { - const batch = intent.cancels.slice(i, i + cancelSize); - for (const c of batch) { - calls.push(this.encodeCancel(c)); + private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[][] { + const batchSize = this.venue.writeBatchSize; + + let batchN = 0; + let qtyCount = 0; // we limit batch by qty count, since gas cost of one cancel approx eq one create order qty=1 + const batch: `0x${string}`[][] = []; + function addTx(tx: `0x${string}`, qty: number) { + if (!batch[batchN]) { + batch[batchN] = new Array(); } + batch[batchN].push(tx); + qtyCount += qty; + if (batch[batchN].length >= batchSize) { + batchN++; + qtyCount = 0; + } + } + + for (const c of intent.cancels) { + addTx(this.encodeCancel(c), 1); } - // Creates: chunk by createBatchSize, each chunk = one createOrders call. - for (let i = 0; i < intent.creates.length; i += createSize) { - const batch = intent.creates.slice(i, i + createSize); - calls.push(this.encodeCreateOrders(batch)); + for (const c of intent.creates) { + addTx(this.encodeCreate(c), Number(c.size)); } - return calls; + return batch; } /** Encode a batch of creates via the `createOrders` contract function. */ diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 02022f8..5bc3795 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -28,9 +28,7 @@ export interface FuturesVenueOptions { /** Max calls per Multicall3 read batch. Default 100. */ readBatchSize: number; /** Max closeOrder calls per cancellation batch. Default 20. */ - cancelBatchSize: number; - /** Max orders per createOrders call. Default 10. */ - createBatchSize: number; + writeBatchSize: number; logger: pino.Logger; } @@ -61,8 +59,7 @@ export class FuturesVenueAdapter implements VenueAdapter { private readonly logger: pino.Logger; private readonly multicall3Address: `0x${string}`; readonly readBatchSize: number; - readonly cancelBatchSize: number; - readonly createBatchSize: number; + readonly writeBatchSize: number; private instrumentSingleton: FuturesInstrumentAdapter | null = null; private vaultAddressCache: `0x${string}` | null = null; @@ -87,8 +84,7 @@ export class FuturesVenueAdapter implements VenueAdapter { throw new Error(`chain ${this.chain.name} has no multicall3 address`); this.multicall3Address = mc3; this.readBatchSize = opts.readBatchSize; - this.cancelBatchSize = opts.cancelBatchSize; - this.createBatchSize = opts.createBatchSize; + this.writeBatchSize = opts.writeBatchSize; this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 45de014..d497e77 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -168,20 +168,13 @@ export const futuresRootSchema = Type.Object( "Calls are chunked transparently; lower values reduce RPC timeouts on busy providers " + "at the cost of more round-trips.", }), - cancelBatchSize: Type.Number({ + writeBatchSize: Type.Number({ minimum: 1, default: 20, description: - "Maximum closeOrder calls per cancellation batch. " + + "Maximum qty per write batch. " + "The adapter groups cancels into chunks of this size before sending.", }), - createBatchSize: Type.Number({ - minimum: 1, - default: 10, - description: - "Maximum orders per createOrders call. " + - "The adapter packs creates into batches of this size.", - }), }, { ...Closed, description: "Titan Market Maker — Futures app config." }, ); diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index b0218b9..967cde7 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -46,8 +46,7 @@ async function main(): Promise { wallet, address: config.venue.address, readBatchSize: config.readBatchSize, - cancelBatchSize: config.cancelBatchSize, - createBatchSize: config.createBatchSize, + writeBatchSize: config.writeBatchSize, logger, }); const instrument = await venue.getInstrument(); diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 3777ac6..4957fc5 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -155,21 +155,12 @@ export const perpsRootSchema = Type.Object( "Maximum number of contract calls bundled into a single Multicall3 read. " + "Calls are chunked transparently; lower values reduce RPC timeouts.", }), - - cancelBatchSize: Type.Number({ - minimum: 1, - default: 30, - description: - "Maximum cancelOrder calls per cancellation batch. " + - "Perps has no batch cancel — each cancel is one call.", - }), - - createBatchSize: Type.Number({ + writeBatchSize: Type.Number({ minimum: 1, - default: 30, + default: 20, description: - "Maximum createOrder calls per creation batch. " + - "Perps has no batch create — each create is one call.", + "Maximum qty per write batch. " + + "The adapter groups cancels into chunks of this size before sending.", }), }, { ...Closed, description: "Titan Market Maker — Perps app config." }, diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 2c6c64d..89a2950 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -46,8 +46,8 @@ async function main(): Promise { wallet, address: config.venue.address, readBatchSize: config.readBatchSize, - cancelBatchSize: config.cancelBatchSize, - createBatchSize: config.createBatchSize, + cancelBatchSize: config.writeBatchSize, + createBatchSize: config.writeBatchSize, logger, }); const instrument = await venue.getInstrument(); From 219e3a19fb03ff05474481daab9dfb911434f4d2 Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 15:28:31 -0400 Subject: [PATCH 053/180] feat(bedrock): add unified keeper ECS scaffolding and fix deploy CI Add 06_col_mar_keeper_svc.tf (single svc-col-mar-keeper-* on the shared derivatives ECS cluster, keeper.{env}.hashpower.exchange). Extend GitHub Actions IAM and dev tfvars (stg/lmn off until GH envs are ready). Replace the merged dual-service deploy-keeper.yml with a single-service workflow that injects the full keeper env block from GitHub Variables and Secrets. Fix keeper-test.yml sibling checkout paths for integration tests. Co-authored-by: Cursor --- .bedrock/.terragrunt/00_outputs.tf | 5 + .bedrock/.terragrunt/00_variables.tf | 26 + .bedrock/.terragrunt/01_github_actions_iam.tf | 64 ++- .bedrock/.terragrunt/06_col_mar_keeper_svc.tf | 305 ++++++++++ .bedrock/02-dev/terraform.tfvars | 9 + .bedrock/03-stg/terraform.tfvars | 8 + .bedrock/04-lmn/terraform.tfvars | 8 + .github/workflows/deploy-keeper.yml | 537 +++++++++--------- .github/workflows/keeper-test.yml | 42 +- keeper/README.md | 42 +- 10 files changed, 748 insertions(+), 298 deletions(-) create mode 100644 .bedrock/.terragrunt/06_col_mar_keeper_svc.tf diff --git a/.bedrock/.terragrunt/00_outputs.tf b/.bedrock/.terragrunt/00_outputs.tf index b68f8b4..81c1e4d 100644 --- a/.bedrock/.terragrunt/00_outputs.tf +++ b/.bedrock/.terragrunt/00_outputs.tf @@ -25,3 +25,8 @@ output "futures_mm_endpoint" { description = "Futures Market Maker health endpoint (internal ALB via VPN)" value = var.futures_mm_service.create ? "https://futuresmm.${local.hp_dns["exc"].name}/health" : null } + +output "col_mar_keeper_endpoint" { + description = "Unified margin keeper health endpoint (internal ALB via VPN)" + value = var.keeper_service.create ? "https://keeper.${local.hp_dns["exc"].name}/health" : null +} diff --git a/.bedrock/.terragrunt/00_variables.tf b/.bedrock/.terragrunt/00_variables.tf index 8534693..92d6f48 100644 --- a/.bedrock/.terragrunt/00_variables.tf +++ b/.bedrock/.terragrunt/00_variables.tf @@ -62,6 +62,32 @@ variable "futures_mm_service" { } } +################################################################################ +# UNIFIED MARGIN KEEPER - SCAFFOLDING ONLY +################################################################################ +# Single ECS service for coordinated perps + futures liquidation (replaces +# derivatives-marketplace svc-perps-keeper-*). Runtime config is owned by +# deploy-keeper.yml via GitHub Variables / Secrets. +################################################################################ + +variable "keeper_service" { + description = "Unified collateral-margin keeper ECS service scaffolding" + type = object({ + create = bool + task_worker_qty = number + cnt_port = number + task_cpu = number + task_ram = number + }) + default = { + create = false + task_worker_qty = 1 + cnt_port = 3000 + task_cpu = 256 + task_ram = 512 + } +} + ################################################################################ # Common Account Variables ################################################################################ diff --git a/.bedrock/.terragrunt/01_github_actions_iam.tf b/.bedrock/.terragrunt/01_github_actions_iam.tf index 59dbd9b..b3bd6b4 100644 --- a/.bedrock/.terragrunt/01_github_actions_iam.tf +++ b/.bedrock/.terragrunt/01_github_actions_iam.tf @@ -1,9 +1,9 @@ ################################################################################ # GITHUB ACTIONS IAM ROLE AND POLICIES ################################################################################ -# Bare-minimum IAM for the deploy-col-mar-mm.yml workflow: +# Bare-minimum IAM for deploy-col-mar-mm.yml and deploy-keeper.yml: # - register new ECS task definitions -# - update both perps + futures services to point at the new revisions +# - update ECS services to point at the new revisions # - PassRole the existing bedrock-foundation-role into ECS tasks # # All runtime config (env vars, secrets, contract addresses, RPC keys) is @@ -182,3 +182,63 @@ resource "aws_iam_role_policy" "github_ecs_update_futures_mm" { ] }) } + +################################################################################ +# ECS UPDATE POLICY - Unified margin keeper service +################################################################################ + +resource "aws_iam_role_policy" "github_ecs_update_keeper" { + count = var.create_core && var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "ecs-update-${local.shortname}-keeper" + role = aws_iam_role.github_actions_collateral_margin[count.index].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "UpdateKeeperECSService" + Effect = "Allow" + Action = [ + "ecs:UpdateService", + "ecs:DescribeServices" + ] + Resource = [ + aws_ecs_service.keeper_use1[count.index].id + ] + }, + { + Sid = "TaskDefinitionOperations" + Effect = "Allow" + Action = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition" + ] + Resource = "*" + }, + { + Sid = "PassRoleToECS" + Effect = "Allow" + Action = "iam:PassRole" + Resource = [ + var.ecs_task_role_arn, + local.titanio_role_arn + ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } + }, + { + Sid = "ReadECSCluster" + Effect = "Allow" + Action = [ + "ecs:ListServices", + "ecs:DescribeClusters" + ] + Resource = "*" + } + ] + }) +} diff --git a/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf new file mode 100644 index 0000000..ac04cfd --- /dev/null +++ b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf @@ -0,0 +1,305 @@ +################################################################################ +# UNIFIED MARGIN KEEPER - ECS SERVICE (SCAFFOLDING) +################################################################################ +# Replaces derivatives-marketplace perps-keeper (svc-perps-keeper-*). +# One long-running task liquidates across vault, PME, perps, and futures. +# +# deploy-keeper.yml owns image, env vars, secrets, and desired_count after +# the first CI/CD deploy. Terraform ships ALB + Route53 at keeper.{env}.* +# (same hostname as the legacy perps keeper once that stack is destroyed). +################################################################################ + +locals { + keeper_env_suffix = substr(var.account_shortname, 8, 3) +} + +################################ +# SECURITY GROUPS +################################ + +resource "aws_security_group" "keeper_alb_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-keeper-alb-${local.keeper_env_suffix}" + description = "Security group for Col-Mar Keeper internal ALB" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTPS from VPC and VPN" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.use1_1.cidr_block, "172.18.0.0/19"] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper ALB Security Group", + Capability = null, + }, + ) +} + +resource "aws_security_group" "keeper_ecs_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-keeper-ecs-${local.keeper_env_suffix}" + description = "Security group for Col-Mar Keeper ECS tasks" + vpc_id = data.aws_vpc.use1_1.id + + ingress { + description = "HTTP from ALB" + from_port = var.keeper_service.cnt_port + to_port = var.keeper_service.cnt_port + protocol = "tcp" + security_groups = [aws_security_group.keeper_alb_use1[count.index].id] + } + + egress { + description = "Allow all outbound (RPC + chain access)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper ECS Security Group", + Capability = null, + }, + ) +} + +################################ +# CLOUDWATCH LOGS +################################ + +resource "aws_cloudwatch_log_group" "keeper_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "/ecs/${local.shortname}-keeper-${local.keeper_env_suffix}" + retention_in_days = 7 + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper ECS Log Group", + Capability = null, + }, + ) +} + +################################ +# APPLICATION LOAD BALANCER (INTERNAL) +################################ + +resource "aws_alb" "keeper_int_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "alb-${local.shortname}-keeper-${local.keeper_env_suffix}" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.keeper_alb_use1[count.index].id] + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + enable_deletion_protection = false + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper Internal ALB", + Capability = null, + }, + ) +} + +resource "aws_alb_target_group" "keeper_int_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "tg-${local.shortname}-keeper-${local.keeper_env_suffix}" + port = tonumber(var.keeper_service.cnt_port) + protocol = "HTTP" + vpc_id = data.aws_vpc.use1_1.id + target_type = "ip" + load_balancing_algorithm_type = "round_robin" + deregistration_delay = "10" + + health_check { + enabled = true + interval = 30 + path = "/health" + port = var.keeper_service.cnt_port + protocol = "HTTP" + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper Target Group", + Capability = null, + }, + ) +} + +resource "aws_alb_listener" "keeper_int_443_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + load_balancer_arn = aws_alb.keeper_int_use1[count.index].arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-FS-1-2-Res-2020-10" + certificate_arn = local.hp_acm["exc"].arn + + default_action { + type = "forward" + target_group_arn = aws_alb_target_group.keeper_int_use1[count.index].arn + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper HTTPS Listener", + Capability = null, + }, + ) +} + +resource "aws_route53_record" "keeper_int_use1" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + zone_id = local.hp_dns["exc"].zone_id + name = "keeper.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.keeper_int_use1[count.index].dns_name + zone_id = aws_alb.keeper_int_use1[count.index].zone_id + evaluate_target_health = true + } +} + +################################ +# ECS SERVICE & TASK +################################ + +resource "aws_ecs_service" "keeper_use1" { + lifecycle { ignore_changes = [task_definition, desired_count] } + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "svc-${local.shortname}-keeper-${local.keeper_env_suffix}" + cluster = data.aws_ecs_cluster.derivatives.arn + task_definition = aws_ecs_task_definition.keeper_use1[count.index].arn + desired_count = 0 + launch_type = "FARGATE" + propagate_tags = "SERVICE" + enable_execute_command = true + + # One liquidator at a time — recreate strategy avoids duplicate txs on deploy. + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = [for m in data.aws_subnet.middle_use1_1 : m.id] + assign_public_ip = false + security_groups = [aws_security_group.keeper_ecs_use1[count.index].id] + } + + load_balancer { + target_group_arn = aws_alb_target_group.keeper_int_use1[count.index].arn + container_name = "${local.shortname}-keeper-container" + container_port = var.keeper_service.cnt_port + } + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper Service", + Capability = null, + }, + ) +} + +resource "aws_ecs_task_definition" "keeper_use1" { + lifecycle { ignore_changes = [container_definitions] } + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + family = "tsk-${local.shortname}-keeper" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.keeper_service.task_cpu + memory = var.keeper_service.task_ram + task_role_arn = local.titanio_role_arn + execution_role_arn = local.titanio_role_arn + + container_definitions = jsonencode([ + { + name = "${local.shortname}-keeper-container" + image = "public.ecr.aws/docker/library/busybox:latest" + command = ["sh", "-c", "echo 'col-mar keeper stub - awaiting CI/CD deploy'; sleep infinity"] + cpu = 0 + essential = true + + portMappings = [ + { + containerPort = tonumber(var.keeper_service.cnt_port) + hostPort = tonumber(var.keeper_service.cnt_port) + protocol = "tcp" + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-create-group" = "true" + "awslogs-group" = aws_cloudwatch_log_group.keeper_use1[0].name + "awslogs-region" = var.default_region + "awslogs-stream-prefix" = "${local.shortname}-keeper-tsk" + } + } + } + ]) + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Col-Mar Keeper ECS Task Definition", + Capability = null, + }, + ) +} + +################################ +# ACCESS INFORMATION +################################ +# DEV: https://keeper.dev.hashpower.exchange/health +# STG: https://keeper.stg.hashpower.exchange/health +# LMN: https://keeper.hashpower.exchange/health +# +# Legacy derivatives perps-keeper must be destroyed first (perpskeeper_service.create=false) +# so this stack can claim the keeper.* Route53 record. diff --git a/.bedrock/02-dev/terraform.tfvars b/.bedrock/02-dev/terraform.tfvars index 332da61..a3d4d55 100644 --- a/.bedrock/02-dev/terraform.tfvars +++ b/.bedrock/02-dev/terraform.tfvars @@ -28,6 +28,15 @@ futures_mm_service = { task_ram = 512 } +# Unified liquidation keeper (replaces derivatives svc-perps-keeper-dev). +keeper_service = { + create = true + task_worker_qty = 1 + cnt_port = 3000 + task_cpu = 256 + task_ram = 512 +} + ######################################## # Account metadata ######################################## diff --git a/.bedrock/03-stg/terraform.tfvars b/.bedrock/03-stg/terraform.tfvars index eb34a12..a2e054d 100644 --- a/.bedrock/03-stg/terraform.tfvars +++ b/.bedrock/03-stg/terraform.tfvars @@ -26,6 +26,14 @@ futures_mm_service = { task_ram = 512 } +keeper_service = { + create = false + task_worker_qty = 1 + cnt_port = 3000 + task_cpu = 256 + task_ram = 512 +} + ######################################## # Account metadata ######################################## diff --git a/.bedrock/04-lmn/terraform.tfvars b/.bedrock/04-lmn/terraform.tfvars index 518f251..ad186ab 100644 --- a/.bedrock/04-lmn/terraform.tfvars +++ b/.bedrock/04-lmn/terraform.tfvars @@ -26,6 +26,14 @@ futures_mm_service = { task_ram = 512 } +keeper_service = { + create = false + task_worker_qty = 1 + cnt_port = 3000 + task_cpu = 256 + task_ram = 512 +} + ######################################## # Account metadata ######################################## diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 05d9677..7014176 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -1,4 +1,41 @@ -name: Deploy Keeper +name: Deploy Collateral Margin Keeper + +# Unified cross-venue liquidation keeper (perps + futures + vault/PME). +# Replaces derivatives-marketplace svc-perps-keeper-*. +# +# Terraform (.bedrock/.terragrunt/06_col_mar_keeper_svc.tf) builds ECS shell, +# internal ALB, Route53 keeper.{env}.hashpower.exchange, and log group. +# This workflow builds the image, pushes to GHCR, and registers task-def +# revisions with full runtime config from GitHub. +# +# GitHub Variables (per environment: dev / stg / main): +# NETWORK e.g. base-sepolia +# VAULT_ADDRESS CollateralVault proxy +# PME_ADDRESS PortfolioMarginEngine proxy +# PERPS_ADDRESS HashPowerPerpsDEX +# FUTURES_ADDRESS Futures proxy +# HASHPRICE_USD_ADDRESS HashpriceUSD aggregator +# BTC_USD_FEED_ADDRESS Chainlink BTC/USDC feed +# ETH_USD_FEED_ADDRESS optional — gas cost logging +# DRY_RUN "true" | "false" (default false) +# KEEPER_MIN_PROFIT_MARGIN default "0" +# KEEPER_DESIRED_COUNT default "1" (set "0" to halt) +# LOG_LEVEL default "info" +# DISCOVERY_MODE events | webhook | both (default events) +# DELIVERY_KEEPER_ENABLED default "false" +# HEALTH_PORT default "3000" (must match TF cnt_port) +# PRICE_MOVE_TRIGGER_BPS default "1" +# SWEEP_INTERVAL_MS default "60000" +# ALERT_WEBHOOK_URL optional Slack/Discord webhook +# +# GitHub Secrets (per environment): +# ALCHEMY_API_KEY RPC (or set ETH_NODE_ADDRESS via var — not secret) +# LIQUIDATOR_PRIVATE_KEY same wallet as legacy perps keeper +# WEBHOOK_SECRET optional — Goldsky bearer token +# +# Repository secrets (all environments): +# AWS_ROLE_ARN_DEV / _STG / _LMN from terragrunt output github_actions_role_arn +# SLACK_WEBHOOK_URL optional — deploy notifications on: push: @@ -6,22 +43,14 @@ on: - dev - stg - main + - "cicd/**" paths: - "keeper/**" - ".github/workflows/deploy-keeper.yml" - workflow_dispatch: inputs: - target: - description: "Which venue keeper to deploy" - required: true - type: choice - options: - - both - - futures - - perps environment: - description: "Target environment" + description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" required: true type: choice options: @@ -39,7 +68,6 @@ env: GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper jobs: - # ── Build (shared across both targets) ───────────────────────────────── build: name: 🔨 Build runs-on: ubuntu-latest @@ -47,6 +75,12 @@ jobs: version: ${{ steps.gen_tag.outputs.version }} tag: ${{ steps.gen_tag.outputs.tag_name }} environment: ${{ steps.gen_tag.outputs.environment }} + env_suffix: ${{ steps.env_config.outputs.env_suffix }} + aws_region: ${{ steps.env_config.outputs.aws_region }} + ecs_cluster: ${{ steps.env_config.outputs.ecs_cluster }} + ecs_service: ${{ steps.env_config.outputs.ecs_service }} + task_family: ${{ steps.env_config.outputs.task_family }} + keeper_health_url: ${{ steps.env_config.outputs.keeper_health_url }} is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} steps: @@ -64,6 +98,37 @@ jobs: major_version: "1" environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + - name: Environment config + id: env_config + run: | + ENV="${{ steps.gen_tag.outputs.environment }}" + echo "aws_region=us-east-1" >> $GITHUB_OUTPUT + echo "task_family=tsk-col-mar-keeper" >> $GITHUB_OUTPUT + + case "$ENV" in + dev) + SUFFIX="dev" + URL_HOST_PREFIX="dev." + ;; + stg) + SUFFIX="stg" + URL_HOST_PREFIX="stg." + ;; + main) + SUFFIX="lmn" + URL_HOST_PREFIX="" + ;; + *) + echo "::error::Unknown environment '$ENV'" + exit 1 + ;; + esac + + echo "env_suffix=${SUFFIX}" >> $GITHUB_OUTPUT + echo "ecs_cluster=ecs-derivatives-marketplace-${SUFFIX}" >> $GITHUB_OUTPUT + echo "ecs_service=svc-col-mar-keeper-${SUFFIX}" >> $GITHUB_OUTPUT + echo "keeper_health_url=https://keeper.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -80,7 +145,6 @@ jobs: TAGS="${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.version }} ${{ env.GHCR_IMAGE }}:${{ steps.gen_tag.outputs.environment }}-latest" - # Add 'latest' tag for main branch if [ "${{ steps.gen_tag.outputs.environment }}" == "main" ]; then TAGS="${TAGS} ${{ env.GHCR_IMAGE }}:latest" @@ -112,279 +176,209 @@ jobs: echo "**Mode:** Test only (no deployment)" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ steps.gen_tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY - # ── Deploy: Futures Keeper ────────────────────────────────────────── - deploy-futures: - name: 🚀 Deploy Futures Keeper + deploy: + name: 🚀 Deploy runs-on: ubuntu-latest needs: build - if: | - needs.build.outputs.is_cicd_branch != 'true' && - (github.event_name != 'workflow_dispatch' || github.event.inputs.target == 'both' || github.event.inputs.target == 'futures') + if: needs.build.outputs.is_cicd_branch != 'true' environment: ${{ needs.build.outputs.environment }} - steps: - - name: Environment config - id: cfg - run: | - ENV="${{ needs.build.outputs.environment }}" - case $ENV in - dev) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-dev" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-futures-dev" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT - ;; - stg) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-stg" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-futures-stg" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT - ;; - main) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-lmn" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-futures-lmn" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-futures" >> $GITHUB_OUTPUT - ;; - esac + steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} - aws-region: ${{ steps.cfg.outputs.aws_region }} - role-session-name: GitHubActions-KeeperFutures-${{ github.run_id }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarKeeper-${{ github.run_id }} - - name: Deploy to ECS + - name: Verify service exists + id: svc_check env: - KEEPER_DRY_RUN: ${{ vars.KEEPER_FUTURES_DRY_RUN || 'false' }} - KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_FUTURES_MIN_PROFIT_MARGIN || '0' }} - FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} - DELIVERY_KEEPER_ENABLED: ${{ vars.KEEPER_FUTURES_DELIVERY_ENABLED || 'false' }} + SERVICE: ${{ needs.build.outputs.ecs_service }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + REGION: ${{ needs.build.outputs.aws_region }} run: | - CLUSTER="${{ steps.cfg.outputs.ecs_cluster }}" - SERVICE="${{ steps.cfg.outputs.ecs_service }}" - TASK_FAMILY="${{ steps.cfg.outputs.task_family }}" - IMAGE="${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}" - REGION="${{ steps.cfg.outputs.aws_region }}" - - echo "🚀 Deploying Futures Keeper to ${{ needs.build.outputs.environment }}" - echo " Cluster: ${CLUSTER}" - echo " Service: ${SERVICE}" - echo " Image: ${IMAGE}" - - aws ecs describe-task-definition \ - --task-definition ${TASK_FAMILY} \ - --region ${REGION} \ - --query 'taskDefinition' > task-def.json - - jq --arg IMAGE "${IMAGE}" \ - '.containerDefinitions[0].image = $IMAGE | - del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ - task-def.json > new-task-def.json - - jq -n '[ - { name: "KEEPER_DRY_RUN", value: env.KEEPER_DRY_RUN }, - { name: "KEEPER_MIN_PROFIT_MARGIN", value: env.KEEPER_MIN_PROFIT_MARGIN }, - { name: "FUTURES_ADDRESS", value: env.FUTURES_ADDRESS }, - { name: "DELIVERY_KEEPER_ENABLED", value: env.DELIVERY_KEEPER_ENABLED } - ]' > env-overrides.json - - jq --slurpfile overrides env-overrides.json ' - .containerDefinitions[0].environment as $existing | - ($overrides[0] | map({(.name): .}) | add) as $override_map | - .containerDefinitions[0].environment = ( - [$existing[] | if $override_map[.name] then .value = $override_map[.name].value else . end] + - [$overrides[0][] | select(.name as $n | [$existing[].name] | index($n) | not)] - ) - ' new-task-def.json > final-task-def.json - mv final-task-def.json new-task-def.json - - NEW_TASK_DEF=$(aws ecs register-task-definition \ - --cli-input-json file://new-task-def.json \ - --region ${REGION} \ - --query 'taskDefinition.taskDefinitionArn' --output text) - - echo "✅ Registered: ${NEW_TASK_DEF}" - - aws ecs update-service \ - --cluster ${CLUSTER} \ - --service ${SERVICE} \ - --task-definition ${NEW_TASK_DEF} \ - --region ${REGION} \ - --force-new-deployment - - echo "✅ Deployment triggered" - - # ── Deploy: Perps Keeper ──────────────────────────────────────────── - deploy-perps: - name: 🚀 Deploy Perps Keeper - runs-on: ubuntu-latest - needs: build - if: | - needs.build.outputs.is_cicd_branch != 'true' && - (github.event_name != 'workflow_dispatch' || github.event.inputs.target == 'both' || github.event.inputs.target == 'perps') - environment: ${{ needs.build.outputs.environment }} - steps: - - name: Environment config - id: cfg - run: | - ENV="${{ needs.build.outputs.environment }}" - case $ENV in - dev) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-dev" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-perps-dev" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT - ;; - stg) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-stg" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-perps-stg" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT - ;; - main) - echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "ecs_cluster=ecs-collateral-margin-lmn" >> $GITHUB_OUTPUT - echo "ecs_service=svc-keeper-perps-lmn" >> $GITHUB_OUTPUT - echo "task_family=tsk-keeper-perps" >> $GITHUB_OUTPUT - ;; - esac - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} - aws-region: ${{ steps.cfg.outputs.aws_region }} - role-session-name: GitHubActions-KeeperPerps-${{ github.run_id }} + ACTIVE_COUNT=$(aws ecs describe-services \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" \ + --query 'services[?status==`ACTIVE`] | length(@)' \ + --output text 2>/dev/null || echo "0") + + if [ "$ACTIVE_COUNT" = "0" ]; then + echo "⚠️ ECS service '$SERVICE' is not ACTIVE in cluster '$CLUSTER'." + echo " Set keeper_service.create=true in bedrock tfvars and apply first." + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "✅ Service '$SERVICE' is active. Proceeding with deploy." + echo "skip=false" >> $GITHUB_OUTPUT + fi - - name: Deploy to ECS + - name: Render new task definition + deploy + if: steps.svc_check.outputs.skip != 'true' env: - KEEPER_DRY_RUN: ${{ vars.KEEPER_PERPS_DRY_RUN || 'false' }} - KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_PERPS_MIN_PROFIT_MARGIN || '0' }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + SERVICE: ${{ needs.build.outputs.ecs_service }} + TASK_FAMILY: ${{ needs.build.outputs.task_family }} + REGION: ${{ needs.build.outputs.aws_region }} + IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} + + NETWORK: ${{ vars.NETWORK }} + VAULT_ADDRESS: ${{ vars.VAULT_ADDRESS }} + PME_ADDRESS: ${{ vars.PME_ADDRESS }} PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} + FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} + HASHPRICE_USD_ADDRESS: ${{ vars.HASHPRICE_USD_ADDRESS }} + BTC_USD_FEED_ADDRESS: ${{ vars.BTC_USD_FEED_ADDRESS }} + ETH_USD_FEED_ADDRESS: ${{ vars.ETH_USD_FEED_ADDRESS }} + DRY_RUN: ${{ vars.DRY_RUN }} + KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_MIN_PROFIT_MARGIN }} + KEEPER_DESIRED_COUNT: ${{ vars.KEEPER_DESIRED_COUNT }} + LOG_LEVEL: ${{ vars.LOG_LEVEL }} + DISCOVERY_MODE: ${{ vars.DISCOVERY_MODE }} + DELIVERY_KEEPER_ENABLED: ${{ vars.DELIVERY_KEEPER_ENABLED }} + HEALTH_PORT: ${{ vars.HEALTH_PORT }} + PRICE_MOVE_TRIGGER_BPS: ${{ vars.PRICE_MOVE_TRIGGER_BPS }} + SWEEP_INTERVAL_MS: ${{ vars.SWEEP_INTERVAL_MS }} + ALERT_WEBHOOK_URL: ${{ vars.ALERT_WEBHOOK_URL }} + ETH_NODE_ADDRESS: ${{ vars.ETH_NODE_ADDRESS }} + + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + LIQUIDATOR_PRIVATE_KEY: ${{ secrets.LIQUIDATOR_PRIVATE_KEY }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | - CLUSTER="${{ steps.cfg.outputs.ecs_cluster }}" - SERVICE="${{ steps.cfg.outputs.ecs_service }}" - TASK_FAMILY="${{ steps.cfg.outputs.task_family }}" - IMAGE="${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}" - REGION="${{ steps.cfg.outputs.aws_region }}" + set -euo pipefail + DESIRED_COUNT="${KEEPER_DESIRED_COUNT:-1}" - echo "🚀 Deploying Perps Keeper to ${{ needs.build.outputs.environment }}" + echo "🚀 Deploying keeper to ${{ needs.build.outputs.environment }}" echo " Cluster: ${CLUSTER}" echo " Service: ${SERVICE}" - echo " Image: ${IMAGE}" + echo " Task Family: ${TASK_FAMILY}" + echo " Image: ${IMAGE}" + echo " DRY_RUN: ${DRY_RUN:-false}" + echo " Desired Count: ${DESIRED_COUNT}" aws ecs describe-task-definition \ - --task-definition ${TASK_FAMILY} \ - --region ${REGION} \ + --task-definition "${TASK_FAMILY}" \ + --region "${REGION}" \ --query 'taskDefinition' > task-def.json - jq --arg IMAGE "${IMAGE}" \ - '.containerDefinitions[0].image = $IMAGE | - del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ - task-def.json > new-task-def.json - - jq -n '[ - { name: "KEEPER_DRY_RUN", value: env.KEEPER_DRY_RUN }, - { name: "KEEPER_MIN_PROFIT_MARGIN", value: env.KEEPER_MIN_PROFIT_MARGIN }, - { name: "PERPS_ADDRESS", value: env.PERPS_ADDRESS } - ]' > env-overrides.json - - jq --slurpfile overrides env-overrides.json ' - .containerDefinitions[0].environment as $existing | - ($overrides[0] | map({(.name): .}) | add) as $override_map | - .containerDefinitions[0].environment = ( - [$existing[] | if $override_map[.name] then .value = $override_map[.name].value else . end] + - [$overrides[0][] | select(.name as $n | [$existing[].name] | index($n) | not)] - ) + jq --arg IMAGE "${IMAGE}" ' + .containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy) + ' task-def.json > new-task-def.json + + jq -n \ + --arg NETWORK "${NETWORK}" \ + --arg VAULT_ADDRESS "${VAULT_ADDRESS}" \ + --arg PME_ADDRESS "${PME_ADDRESS}" \ + --arg PERPS_ADDRESS "${PERPS_ADDRESS}" \ + --arg FUTURES_ADDRESS "${FUTURES_ADDRESS}" \ + --arg HASHPRICE_USD_ADDRESS "${HASHPRICE_USD_ADDRESS}" \ + --arg BTC_USD_FEED_ADDRESS "${BTC_USD_FEED_ADDRESS}" \ + --arg ETH_USD_FEED_ADDRESS "${ETH_USD_FEED_ADDRESS:-}" \ + --arg DRY_RUN "${DRY_RUN:-false}" \ + --arg KEEPER_MIN_PROFIT_MARGIN "${KEEPER_MIN_PROFIT_MARGIN:-0}" \ + --arg LOG_LEVEL "${LOG_LEVEL:-info}" \ + --arg DISCOVERY_MODE "${DISCOVERY_MODE:-events}" \ + --arg DELIVERY_KEEPER_ENABLED "${DELIVERY_KEEPER_ENABLED:-false}" \ + --arg HEALTH_PORT "${HEALTH_PORT:-3000}" \ + --arg PRICE_MOVE_TRIGGER_BPS "${PRICE_MOVE_TRIGGER_BPS:-1}" \ + --arg SWEEP_INTERVAL_MS "${SWEEP_INTERVAL_MS:-60000}" \ + --arg ALERT_WEBHOOK_URL "${ALERT_WEBHOOK_URL:-}" \ + --arg ETH_NODE_ADDRESS "${ETH_NODE_ADDRESS:-}" \ + --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ + --arg LIQUIDATOR_PRIVATE_KEY "${LIQUIDATOR_PRIVATE_KEY}" \ + --arg WEBHOOK_SECRET "${WEBHOOK_SECRET:-}" \ + '[ + {name:"NETWORK", value:$NETWORK}, + {name:"VAULT_ADDRESS", value:$VAULT_ADDRESS}, + {name:"PME_ADDRESS", value:$PME_ADDRESS}, + {name:"PERPS_ADDRESS", value:$PERPS_ADDRESS}, + {name:"FUTURES_ADDRESS", value:$FUTURES_ADDRESS}, + {name:"HASHPRICE_USD_ADDRESS", value:$HASHPRICE_USD_ADDRESS}, + {name:"BTC_USD_FEED_ADDRESS", value:$BTC_USD_FEED_ADDRESS}, + {name:"ETH_USD_FEED_ADDRESS", value:$ETH_USD_FEED_ADDRESS}, + {name:"DRY_RUN", value:$DRY_RUN}, + {name:"KEEPER_MIN_PROFIT_MARGIN", value:$KEEPER_MIN_PROFIT_MARGIN}, + {name:"LOG_LEVEL", value:$LOG_LEVEL}, + {name:"DISCOVERY_MODE", value:$DISCOVERY_MODE}, + {name:"DELIVERY_KEEPER_ENABLED", value:$DELIVERY_KEEPER_ENABLED}, + {name:"HEALTH_PORT", value:$HEALTH_PORT}, + {name:"PRICE_MOVE_TRIGGER_BPS", value:$PRICE_MOVE_TRIGGER_BPS}, + {name:"SWEEP_INTERVAL_MS", value:$SWEEP_INTERVAL_MS}, + {name:"ALERT_WEBHOOK_URL", value:$ALERT_WEBHOOK_URL}, + {name:"ETH_NODE_ADDRESS", value:$ETH_NODE_ADDRESS}, + {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, + {name:"LIQUIDATOR_PRIVATE_KEY", value:$LIQUIDATOR_PRIVATE_KEY}, + {name:"WEBHOOK_SECRET", value:$WEBHOOK_SECRET} + ]' > env-block.json + + jq --slurpfile env env-block.json ' + .containerDefinitions[0].environment = $env[0] | + del(.containerDefinitions[0].secrets) ' new-task-def.json > final-task-def.json - mv final-task-def.json new-task-def.json NEW_TASK_DEF=$(aws ecs register-task-definition \ - --cli-input-json file://new-task-def.json \ - --region ${REGION} \ + --cli-input-json file://final-task-def.json \ + --region "${REGION}" \ --query 'taskDefinition.taskDefinitionArn' --output text) echo "✅ Registered: ${NEW_TASK_DEF}" aws ecs update-service \ - --cluster ${CLUSTER} \ - --service ${SERVICE} \ - --task-definition ${NEW_TASK_DEF} \ - --region ${REGION} \ - --force-new-deployment + --cluster "${CLUSTER}" \ + --service "${SERVICE}" \ + --task-definition "${NEW_TASK_DEF}" \ + --desired-count "${DESIRED_COUNT}" \ + --region "${REGION}" \ + --force-new-deployment > /dev/null echo "✅ Deployment triggered" - # ── Verify (futures) ───────────────────────────────────────────────── - verify-futures: - name: 🔍 Verify Futures + verify: + name: 🔍 Verify runs-on: ubuntu-latest - needs: [build, deploy-futures] - if: | - always() && - needs.build.outputs.is_cicd_branch != 'true' && - needs.deploy-futures.result == 'success' + needs: [build, deploy] + if: needs.build.outputs.is_cicd_branch != 'true' steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} - aws-region: us-east-1 - role-session-name: GitHubActions-KeeperFutures-Verify-${{ github.run_id }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarKeeper-Verify-${{ github.run_id }} - name: Wait for service to stabilize + env: + SERVICE: ${{ needs.build.outputs.ecs_service }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + REGION: ${{ needs.build.outputs.aws_region }} run: | - ENV="${{ needs.build.outputs.environment }}" - case $ENV in - dev) CLUSTER="ecs-collateral-margin-dev"; SERVICE="svc-keeper-futures-dev" ;; - stg) CLUSTER="ecs-collateral-margin-stg"; SERVICE="svc-keeper-futures-stg" ;; - main) CLUSTER="ecs-collateral-margin-lmn"; SERVICE="svc-keeper-futures-lmn" ;; - esac - aws ecs wait services-stable \ - --cluster ${CLUSTER} \ - --services ${SERVICE} \ - --region us-east-1 - echo "✅ Futures keeper stable" - - # ── Verify (perps) ─────────────────────────────────────────────────── - verify-perps: - name: 🔍 Verify Perps - runs-on: ubuntu-latest - needs: [build, deploy-perps] - if: | - always() && - needs.build.outputs.is_cicd_branch != 'true' && - needs.deploy-perps.result == 'success' - - steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} - aws-region: us-east-1 - role-session-name: GitHubActions-KeeperPerps-Verify-${{ github.run_id }} + ACTIVE=$(aws ecs describe-services \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" \ + --query 'services[?status==`ACTIVE`] | length(@)' \ + --output text 2>/dev/null || echo "0") + if [ "$ACTIVE" = "0" ]; then + echo "⚠️ Service $SERVICE not active — nothing to verify." + exit 0 + fi - - name: Wait for service to stabilize - run: | - ENV="${{ needs.build.outputs.environment }}" - case $ENV in - dev) CLUSTER="ecs-collateral-margin-dev"; SERVICE="svc-keeper-perps-dev" ;; - stg) CLUSTER="ecs-collateral-margin-stg"; SERVICE="svc-keeper-perps-stg" ;; - main) CLUSTER="ecs-collateral-margin-lmn"; SERVICE="svc-keeper-perps-lmn" ;; - esac + echo "⏳ Waiting for $SERVICE to stabilize..." aws ecs wait services-stable \ - --cluster ${CLUSTER} \ - --services ${SERVICE} \ - --region us-east-1 - echo "✅ Perps keeper stable" + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "$REGION" + echo "✅ Stable" - # ── Tag + notify ───────────────────────────────────────────────────── cleanup: - name: 🧹 Tag & Notify + name: 🧹 Cleanup runs-on: ubuntu-latest - needs: [build, deploy-futures, deploy-perps, verify-futures, verify-perps] + needs: [build, verify] if: always() && needs.build.outputs.is_cicd_branch != 'true' steps: @@ -394,39 +388,74 @@ jobs: fetch-depth: 0 - name: Configure Git - if: needs.verify-futures.result == 'success' || needs.verify-perps.result == 'success' + if: needs.verify.result == 'success' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Create and push tag - if: needs.verify-futures.result == 'success' || needs.verify-perps.result == 'success' + if: needs.verify.result == 'success' run: | TAG_NAME="${{ needs.build.outputs.tag }}" - echo "🏷️ Creating tag: $TAG_NAME" if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "⚠️ Tag already exists, skipping" + echo "⚠️ Tag $TAG_NAME already exists, skipping" else - git tag -a "$TAG_NAME" -m "Release ${{ needs.build.outputs.version }}" + git tag -a "$TAG_NAME" -m "Release ${{ needs.build.outputs.version }} - Keeper deployed to ${{ needs.build.outputs.environment }}" git push origin "$TAG_NAME" echo "✅ Tag pushed" fi - name: Deployment summary + if: needs.verify.result == 'success' run: | - echo "## 🎉 Keeper Deployment" >> $GITHUB_STEP_SUMMARY + echo "## 🎉 Keeper Deployment Complete" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**Environment:** ${{ needs.build.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**Image:** \`${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ needs.verify-futures.result }}" == "success" ]; then - echo "✅ Futures keeper deployed and stable" >> $GITHUB_STEP_SUMMARY - else - echo "❌ Futures keeper: ${{ needs.verify-futures.result }}" >> $GITHUB_STEP_SUMMARY - fi - if [ "${{ needs.verify-perps.result }}" == "success" ]; then - echo "✅ Perps keeper deployed and stable" >> $GITHUB_STEP_SUMMARY + echo "**Health:** ${{ needs.build.outputs.keeper_health_url }}" >> $GITHUB_STEP_SUMMARY + echo "**Cluster:** ${{ needs.build.outputs.ecs_cluster }}" >> $GITHUB_STEP_SUMMARY + echo "**Service:** ${{ needs.build.outputs.ecs_service }}" >> $GITHUB_STEP_SUMMARY + + - name: Failure summary + if: needs.verify.result == 'failure' + run: | + echo "## ❌ Keeper Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "Check deploy and verify job logs." >> $GITHUB_STEP_SUMMARY + + notify: + name: 📢 Notify + runs-on: ubuntu-latest + needs: [build, deploy, verify, cleanup] + if: always() && needs.build.result == 'success' + + steps: + - name: Checkout (for composite action) + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine status + id: status + run: | + if [ "${{ needs.build.outputs.is_cicd_branch }}" == "true" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "success" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "failure" ] || [ "${{ needs.deploy.result }}" == "failure" ]; then + echo "status=failure" >> $GITHUB_OUTPUT else - echo "❌ Perps keeper: ${{ needs.verify-perps.result }}" >> $GITHUB_STEP_SUMMARY + echo "status=skipped" >> $GITHUB_OUTPUT fi + + - name: Send Slack notification + uses: ./.github/actions/slack-notify + with: + status: ${{ steps.status.outputs.status }} + environment: ${{ needs.build.outputs.environment }} + service_name: "Collateral Margin Keeper" + version: ${{ needs.build.outputs.version }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} + additional_info: "${{ needs.build.outputs.is_cicd_branch == 'true' && '*Mode:* CI/CD test build only' || format('*Health:* <{0}|keeper> • *Cluster:* `{1}` • *Service:* `{2}`', needs.build.outputs.keeper_health_url, needs.build.outputs.ecs_cluster, needs.build.outputs.ecs_service) }}" diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 2c90c1c..47dfcd3 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -22,64 +22,61 @@ defaults: run: shell: bash +permissions: + contents: read + jobs: test: name: Test runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: Checkout collateral-margin - uses: actions/checkout@v6 - with: - path: collateral-margin + uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 with: - package_json_file: collateral-margin/keeper/package.json + package_json_file: keeper/package.json - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: "24" cache: "pnpm" - cache-dependency-path: collateral-margin/keeper/pnpm-lock.yaml + cache-dependency-path: keeper/pnpm-lock.yaml - name: Install keeper dependencies - working-directory: collateral-margin/keeper + working-directory: ./keeper run: pnpm install --frozen-lockfile - name: TypeCheck - working-directory: collateral-margin/keeper + working-directory: ./keeper run: pnpm typecheck - name: Unit tests - working-directory: collateral-margin/keeper + working-directory: ./keeper run: pnpm test - name: Install contracts dependencies - working-directory: collateral-margin/contracts + working-directory: ./contracts run: pnpm install --frozen-lockfile - - name: Checkout perps - uses: actions/checkout@v6 + - name: Checkout derivatives-marketplace (perps contracts) + uses: actions/checkout@v4 with: repository: Lumerin-protocol/derivatives-marketplace ref: dev path: perps - name: Checkout futures-marketplace - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: repository: Lumerin-protocol/futures-marketplace ref: dev path: futures-marketplace - - name: List folders - working-directory: ../../ - run: find . -maxdepth 5 -type d | sort - - name: Install perps contracts dependencies working-directory: perps/contracts run: pnpm install --frozen-lockfile @@ -88,10 +85,9 @@ jobs: working-directory: futures-marketplace/contracts run: pnpm install --frozen-lockfile - - name: Compile siblings - working-directory: collateral-margin/keeper - run: pnpm pretest:integration - - name: Integration tests - working-directory: collateral-margin/keeper + working-directory: ./keeper + env: + PERPS_REPO: ${{ github.workspace }}/perps/contracts + FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts run: pnpm test:integration diff --git a/keeper/README.md b/keeper/README.md index b3110f2..addbf19 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -178,25 +178,29 @@ pre-cutover validation step: point dry-run at the production RPC for a few hours and grep the logs for `[dryRun] would send liquidate tx` to confirm the keeper would have triggered exactly when the legacy systems did. -## Cutover plan - -This package replaces both `futures-marketplace/margin-call/` (Lambda) and -`perps/keeper/` (single-venue keeper). The contracts in Phase 0 added -permissionless `liquidate*` entry points alongside the legacy paths so -cutover is staged: - -1. **Deploy** with `DRY_RUN=true` against production RPC. Verify alert - webhook + healthcheck. Compare planned actions against the live Lambda / - keeper logs for at least one liquidation cycle. -2. **Promote**: flip `DRY_RUN=false`. Leave the legacy systems running for a - day as a fallback — the contracts dedupe (you cannot liquidate the same - underwater account twice). -3. **Decommission** the legacy `margin-call` Lambda and `perps/keeper` - service. Re-balance alert routing to point only at this keeper. -4. **Cleanup** (separate PR): the futures contract's `marginCall` (validator- - only) entry point was preserved during Phase 0b for backward - compatibility. Once this keeper owns production traffic, that path can be - removed in a follow-up upgrade — see the plan's Phase 4. +## AWS deployment + +Infrastructure: `.bedrock/.terragrunt/06_col_mar_keeper_svc.tf` (single ECS service +`svc-col-mar-keeper-{dev|stg|lmn}` on `ecs-derivatives-marketplace-*`, health at +`https://keeper.{env}.hashpower.exchange/health`). + +CI/CD: `.github/workflows/deploy-keeper.yml` — see the workflow header for required +GitHub Environment variables and secrets (`LIQUIDATOR_PRIVATE_KEY`, `VAULT_ADDRESS`, +`PME_ADDRESS`, oracle feeds, etc.). + +## Cutover runbook + +Replaces `derivatives-marketplace` `svc-perps-keeper-*` (no futures liquidation +lambda in current bedrock). + +1. **derivatives-marketplace:** `perpskeeper_service.create = false` in bedrock + tfvars → `terragrunt apply` (removes legacy keeper ECS + `keeper.*` DNS). +2. **collateral-margin:** `keeper_service.create = true` (dev) → `terragrunt apply`. +3. **GitHub `dev` environment:** add keeper vars/secrets (copy liquidator key from + `perps-keeper-secrets-v3-dev` in AWS SM). Set `DRY_RUN=true` initially. +4. **Merge / push** `dev` → `deploy-keeper` rolls the image and scales the service. +5. Verify `/health`, logs, dry-run liquidation lines; then `DRY_RUN=false`. +6. Repeat for stg/main (`keeper_service.create = true` + populate GH environments). ## Test surface From d277e70f7829943cba18e15a7705cd81c229694e Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 15:51:30 -0400 Subject: [PATCH 054/180] fix(keeper): repair CI tests and Docker build after keeper infra merge Align PERPS_REPO/FUTURES_REPO with artifact loader (repo roots), copy pnpm patches into the image, vendor errSerializer for keeper-only Docker context, and opt GitHub Actions into Node 24 for action runtimes. Co-authored-by: Cursor --- .github/workflows/deploy-keeper.yml | 1 + .github/workflows/keeper-test.yml | 7 ++- keeper/Dockerfile | 3 +- keeper/scripts/compile-siblings.ts | 30 +++++++-- keeper/src/index.ts | 2 +- keeper/src/util/errSerializer.ts | 96 +++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 keeper/src/util/errSerializer.ts diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 7014176..f3375a4 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -64,6 +64,7 @@ permissions: packages: write env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true GHCR_REGISTRY: ghcr.io GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 47dfcd3..1d5b38c 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -25,6 +25,9 @@ defaults: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: test: name: Test @@ -88,6 +91,6 @@ jobs: - name: Integration tests working-directory: ./keeper env: - PERPS_REPO: ${{ github.workspace }}/perps/contracts - FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts + PERPS_REPO: ${{ github.workspace }}/perps + FUTURES_REPO: ${{ github.workspace }}/futures-marketplace run: pnpm test:integration diff --git a/keeper/Dockerfile b/keeper/Dockerfile index 0621f9a..bd12843 100644 --- a/keeper/Dockerfile +++ b/keeper/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches/ patches/ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── diff --git a/keeper/scripts/compile-siblings.ts b/keeper/scripts/compile-siblings.ts index 266c446..78872b7 100644 --- a/keeper/scripts/compile-siblings.ts +++ b/keeper/scripts/compile-siblings.ts @@ -18,8 +18,10 @@ * imports — and the keeper just reads the resulting artifact JSON. * * Path resolution mirrors `tests/integration/artifacts.ts`: - * PERPS_REPO – defaults to ../../perps - * FUTURES_REPO – defaults to ../../futures-marketplace + * PERPS_REPO – sibling repo root (…/perps); defaults to ../../perps + * FUTURES_REPO – sibling repo root; defaults to ../../futures-marketplace + * + * Each repo's Hardhat project lives in `/contracts`. * * Compilation is skipped when `SKIP_COMPILE_SIBLINGS=1` (used in CI when * the artifacts have already been built upstream and committed). @@ -37,12 +39,32 @@ if (process.env.SKIP_COMPILE_SIBLINGS === "1") { const here = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(here, "..", "..", ".."); +/** Repo root env var → Hardhat package dir (`/contracts`). */ +function contractsPackageDir(repoRootEnv: string | undefined, defaultRepoRoot: string): string { + const root = repoRootEnv ?? defaultRepoRoot; + const pkg = resolve(root, "contracts"); + if (existsSync(resolve(pkg, "hardhat.config.ts")) || existsSync(resolve(pkg, "hardhat.config.js"))) { + return pkg; + } + // Legacy: env pointed directly at the contracts package. + if (existsSync(resolve(root, "hardhat.config.ts")) || existsSync(resolve(root, "hardhat.config.js"))) { + return root; + } + return pkg; +} + const targets = [ { name: "collateral-margin", dir: resolve(here, "..", "..", "contracts") }, - { name: "perps", dir: process.env.PERPS_REPO ?? resolve(workspaceRoot, "perps", "contracts") }, + { + name: "perps", + dir: contractsPackageDir(process.env.PERPS_REPO, resolve(workspaceRoot, "perps")), + }, { name: "futures-marketplace", - dir: process.env.FUTURES_REPO ?? resolve(workspaceRoot, "futures-marketplace", "contracts"), + dir: contractsPackageDir( + process.env.FUTURES_REPO, + resolve(workspaceRoot, "futures-marketplace"), + ), }, ]; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 3609b7e..21705a9 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,6 +1,6 @@ import pino from "pino"; import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; -import { serializeError } from "../../market-maker/src/core/errSerializer.ts"; +import { serializeError } from "./util/errSerializer.ts"; import { loadConfig } from "./config.ts"; import { createChain } from "./chain.ts"; import { ParticipantTracker } from "./discovery/tracker.ts"; diff --git a/keeper/src/util/errSerializer.ts b/keeper/src/util/errSerializer.ts new file mode 100644 index 0000000..3bbacdc --- /dev/null +++ b/keeper/src/util/errSerializer.ts @@ -0,0 +1,96 @@ +/** + * viem errors nest 4-5 cause levels deep, and every level re-stringifies the + * full multicall calldata into its `message`, `stack`, and `metaMessages`. + * Naively serializing with `pino.stdSerializers.errWithCause` produces tens + * of KB of duplicated hex per failed call. + * + * This serializer instead walks the cause chain once and emits a flat, + * minimal payload: `name`, `message` (preferring viem's `shortMessage`), the + * decoded custom error (`errorName`, e.g. `"FailedCall"`), a trimmed `data` + * hex selector/blob, and a single frames-only `stack` from the top error. + */ + +const MAX_DATA_LEN = 200; + +function isObj(v: unknown): v is Record { + return v !== null && typeof v === "object"; +} + +function* walkCauses(err: unknown): Generator> { + const seen = new Set(); + let cur: unknown = err; + while (isObj(cur) && !seen.has(cur)) { + seen.add(cur); + yield cur; + cur = (cur as Record).cause; + } +} + +function pickString(o: Record, k: string): string | undefined { + const v = o[k]; + return typeof v === "string" ? v : undefined; +} + +function firstLine(s: string): string { + const idx = s.indexOf("\n"); + return idx === -1 ? s : s.slice(0, idx); +} + +function shortMessageOf(lvl: Record): string | undefined { + const sm = pickString(lvl, "shortMessage"); + if (sm) return sm; + const m = pickString(lvl, "message"); + return m === undefined ? undefined : firstLine(m); +} + +function stackFrames(stack: unknown): string { + if (typeof stack !== "string") return ""; + return stack + .split("\n") + .filter((l) => /^\s*at /.test(l)) + .join("\n"); +} + +function trimHex(s: string): string { + return s.length <= MAX_DATA_LEN ? s : `${s.slice(0, MAX_DATA_LEN)}…<+${s.length - MAX_DATA_LEN} chars>`; +} + +export function serializeError(err: unknown): Record { + if (err === null || typeof err !== "object" || !(err instanceof Error)) { + return { raw: err }; + } + + const chain = [...walkCauses(err)]; + const top = chain[0] ?? {}; + + let errorName: string | undefined; + let data: string | undefined; + for (const lvl of chain) { + if (errorName === undefined && isObj(lvl.data)) { + errorName = pickString(lvl.data as Record, "errorName"); + } + if (data === undefined && typeof lvl.data === "string") { + data = trimHex(lvl.data); + } + if (errorName !== undefined && data !== undefined) break; + } + + let stack = ""; + for (const lvl of chain) { + stack = stackFrames(lvl.stack); + if (stack) break; + } + + const name = pickString(top, "name") ?? err.name ?? "Error"; + const message = shortMessageOf(top) ?? "(no message)"; + + const out: Record = { name, message }; + if (errorName) out.errorName = errorName; + if (data !== undefined) out.data = data; + if (stack) out.stack = stack; + for (const k of ["contractAddress", "functionName", "sender", "tenderlyUrl"] as const) { + const v = pickString(top, k); + if (v) out[k] = v; + } + return out; +} From 7d808e3639c1c0307ea03cfeba44e79201008d4e Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 16:00:41 -0400 Subject: [PATCH 055/180] ci(keeper): use Node 24-native GitHub Actions Bump action versions in keeper-test.yml and deploy-keeper.yml only; remove FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 workaround. Co-authored-by: Cursor --- .github/workflows/deploy-keeper.yml | 17 ++++++++--------- .github/workflows/keeper-test.yml | 13 +++++-------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index f3375a4..652c875 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -64,7 +64,6 @@ permissions: packages: write env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true GHCR_REGISTRY: ghcr.io GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper @@ -86,7 +85,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -131,10 +130,10 @@ jobs: echo "keeper_health_url=https://keeper.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -156,7 +155,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./keeper push: ${{ steps.gen_tag.outputs.is_cicd_branch != 'true' }} @@ -186,7 +185,7 @@ jobs: steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} @@ -346,7 +345,7 @@ jobs: steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} @@ -384,7 +383,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -432,7 +431,7 @@ jobs: steps: - name: Checkout (for composite action) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 2 diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 1d5b38c..2040c95 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -25,9 +25,6 @@ defaults: permissions: contents: read -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: test: name: Test @@ -36,15 +33,15 @@ jobs: steps: - name: Checkout collateral-margin - 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: keeper/package.json - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" @@ -67,14 +64,14 @@ jobs: run: pnpm install --frozen-lockfile - name: Checkout derivatives-marketplace (perps contracts) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace ref: dev path: perps - name: Checkout futures-marketplace - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace ref: dev From 03715e1a9493d44e7a89558afe8c035756d0b8cd Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 16:34:45 -0400 Subject: [PATCH 056/180] fix(ci): clear Terraform stub command on keeper ECS deploy Deploy was swapping the GHCR image but keeping the bedrock stub `command` (busybox sleep), so /health never came up. Drop command and entryPoint so the image CMD runs the real keeper process. Co-authored-by: Cursor --- .github/workflows/deploy-keeper.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 652c875..e94af19 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -265,6 +265,7 @@ jobs: jq --arg IMAGE "${IMAGE}" ' .containerDefinitions[0].image = $IMAGE | + del(.containerDefinitions[0].command, .containerDefinitions[0].entryPoint) | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy) ' task-def.json > new-task-def.json @@ -317,7 +318,7 @@ jobs: jq --slurpfile env env-block.json ' .containerDefinitions[0].environment = $env[0] | - del(.containerDefinitions[0].secrets) + del(.containerDefinitions[0].secrets, .containerDefinitions[0].command, .containerDefinitions[0].entryPoint) ' new-task-def.json > final-task-def.json NEW_TASK_DEF=$(aws ecs register-task-definition \ From fe3a7dfda0631ec043852351f21c3139ad676c2f Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 16:37:22 -0400 Subject: [PATCH 057/180] fix(keeper): split Docker CMD flags for amaro import Exec-form CMD must pass --import and script as separate argv entries. A single combined flag made Node resolve amaro/strip --conditions=typescript and exit with ERR_PACKAGE_PATH_NOT_EXPORTED on ECS. Co-authored-by: Cursor --- keeper/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/Dockerfile b/keeper/Dockerfile index bd12843..51323f6 100644 --- a/keeper/Dockerfile +++ b/keeper/Dockerfile @@ -23,4 +23,4 @@ COPY src/ src/ RUN addgroup -S keeper && adduser -S keeper -G keeper USER keeper -CMD ["node", "--import=amaro/strip --conditions=typescript", "src/index.ts"] +CMD ["node", "--import=amaro/strip", "src/index.ts"] From e81ed30050c769ad657a09a9808cede88c19e89a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 8 Jun 2026 14:11:05 +0200 Subject: [PATCH 058/180] feat(points): add on-chain points program (Points, hook, redeemer) + subgraph Implement the non-transferable Hashrate Points (HP) ledger, the PointsHook accrual logic for perps/futures fills and liquidations, and the PointsRedeemer that burns HP for a pro-rata GOV payout (50/50 liquid + vesting escrow). Add Hardhat tests, deploy script, and a dedicated points-indexer subgraph that mirrors balances and the maker/taker/keeper category breakdown from hook events. Reconcile points-system-design.md with the shipped design and record deferred features (referral, loyalty, per-account caps) in points-system-improvements.md. --- contracts/abi/ContractErrors.json | 81 + contracts/abi/ContractErrors.ts | 81 + contracts/abi/IPoints.json | 229 ++ contracts/abi/IPoints.ts | 229 ++ contracts/abi/IPointsHook.json | 53 + contracts/abi/IPointsHook.ts | 53 + contracts/abi/Points.json | 649 +++ contracts/abi/Points.ts | 649 +++ contracts/abi/PointsHook.json | 835 ++++ contracts/abi/PointsHook.ts | 835 ++++ contracts/abi/PointsRedeemer.json | 344 ++ contracts/abi/PointsRedeemer.ts | 344 ++ contracts/contracts/Points.sol | 130 + contracts/contracts/PointsHook.sol | 136 + contracts/contracts/PointsRedeemer.sol | 121 + contracts/contracts/interfaces/IPoints.sol | 23 + .../contracts/interfaces/IPointsHook.sol | 26 + contracts/contracts/mocks/GovTokenMock.sol | 19 + .../contracts/mocks/VestingEscrowMock.sol | 17 + contracts/hardhat.config.ts | 5 + contracts/package.json | 1 + contracts/scripts/deploy-points.ts | 161 + contracts/tests/points.test.ts | 164 + contracts/tests/pointsFixtures.ts | 76 + contracts/tests/pointsHook.test.ts | 100 + contracts/tests/pointsRedeemer.test.ts | 158 + docs/points-system-design.md | 234 ++ docs/points-system-improvements.md | 142 + points-indexer/.env.example | 16 + points-indexer/.gitignore | 11 + points-indexer/README.md | 66 + points-indexer/package.json | 34 + points-indexer/pnpm-lock.yaml | 3610 +++++++++++++++++ points-indexer/schema.graphql | 106 + points-indexer/src/ids.ts | 6 + points-indexer/src/points.ts | 236 ++ points-indexer/subgraph.template.yaml | 86 + points-indexer/tests/helpers.ts | 49 + points-indexer/tests/points.test.ts | 196 + points-indexer/tsconfig.json | 23 + points-indexer/types/ambient.d.ts | 9 + 41 files changed, 10343 insertions(+) create mode 100644 contracts/abi/IPoints.json create mode 100644 contracts/abi/IPoints.ts create mode 100644 contracts/abi/IPointsHook.json create mode 100644 contracts/abi/IPointsHook.ts create mode 100644 contracts/abi/Points.json create mode 100644 contracts/abi/Points.ts create mode 100644 contracts/abi/PointsHook.json create mode 100644 contracts/abi/PointsHook.ts create mode 100644 contracts/abi/PointsRedeemer.json create mode 100644 contracts/abi/PointsRedeemer.ts create mode 100644 contracts/contracts/Points.sol create mode 100644 contracts/contracts/PointsHook.sol create mode 100644 contracts/contracts/PointsRedeemer.sol create mode 100644 contracts/contracts/interfaces/IPoints.sol create mode 100644 contracts/contracts/interfaces/IPointsHook.sol create mode 100644 contracts/contracts/mocks/GovTokenMock.sol create mode 100644 contracts/contracts/mocks/VestingEscrowMock.sol create mode 100644 contracts/scripts/deploy-points.ts create mode 100644 contracts/tests/points.test.ts create mode 100644 contracts/tests/pointsFixtures.ts create mode 100644 contracts/tests/pointsHook.test.ts create mode 100644 contracts/tests/pointsRedeemer.test.ts create mode 100644 docs/points-system-design.md create mode 100644 docs/points-system-improvements.md create mode 100644 points-indexer/.env.example create mode 100644 points-indexer/.gitignore create mode 100644 points-indexer/README.md create mode 100644 points-indexer/package.json create mode 100644 points-indexer/pnpm-lock.yaml create mode 100644 points-indexer/schema.graphql create mode 100644 points-indexer/src/ids.ts create mode 100644 points-indexer/src/points.ts create mode 100644 points-indexer/subgraph.template.yaml create mode 100644 points-indexer/tests/helpers.ts create mode 100644 points-indexer/tests/points.test.ts create mode 100644 points-indexer/tsconfig.json create mode 100644 points-indexer/types/ambient.d.ts diff --git a/contracts/abi/ContractErrors.json b/contracts/abi/ContractErrors.json index 9045af7..9ac3a06 100644 --- a/contracts/abi/ContractErrors.json +++ b/contracts/abi/ContractErrors.json @@ -1,4 +1,9 @@ [ + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, { "inputs": [ { @@ -10,6 +15,11 @@ "name": "Error", "type": "error" }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, { "inputs": [ { @@ -21,6 +31,11 @@ "name": "OwnableUnauthorizedAccount", "type": "error" }, + { + "inputs": [], + "name": "NotFinalized", + "type": "error" + }, { "inputs": [ { @@ -37,6 +52,16 @@ "name": "ZeroAmount", "type": "error" }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [], + "name": "AlreadyFinalized", + "type": "error" + }, { "inputs": [ { @@ -70,11 +95,36 @@ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReferrer", + "type": "error" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, { "inputs": [], "name": "MarginBreach", "type": "error" }, + { + "inputs": [], + "name": "TransferNotAllowed", + "type": "error" + }, { "inputs": [ { @@ -86,6 +136,11 @@ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "ReferrerAlreadySet", + "type": "error" + }, { "inputs": [ { @@ -149,6 +204,22 @@ "name": "UUPSUnauthorizedCallContext", "type": "error" }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, { "inputs": [ { @@ -197,6 +268,16 @@ "name": "ERC20InvalidReceiver", "type": "error" }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", diff --git a/contracts/abi/ContractErrors.ts b/contracts/abi/ContractErrors.ts index 6fad5b6..b88c1de 100644 --- a/contracts/abi/ContractErrors.ts +++ b/contracts/abi/ContractErrors.ts @@ -1,4 +1,9 @@ export const contractErrors = [ + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, { "inputs": [ { @@ -10,6 +15,11 @@ export const contractErrors = [ "name": "Error", "type": "error" }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, { "inputs": [ { @@ -21,6 +31,11 @@ export const contractErrors = [ "name": "OwnableUnauthorizedAccount", "type": "error" }, + { + "inputs": [], + "name": "NotFinalized", + "type": "error" + }, { "inputs": [ { @@ -37,6 +52,16 @@ export const contractErrors = [ "name": "ZeroAmount", "type": "error" }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [], + "name": "AlreadyFinalized", + "type": "error" + }, { "inputs": [ { @@ -70,11 +95,36 @@ export const contractErrors = [ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReferrer", + "type": "error" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, { "inputs": [], "name": "MarginBreach", "type": "error" }, + { + "inputs": [], + "name": "TransferNotAllowed", + "type": "error" + }, { "inputs": [ { @@ -86,6 +136,11 @@ export const contractErrors = [ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "ReferrerAlreadySet", + "type": "error" + }, { "inputs": [ { @@ -149,6 +204,22 @@ export const contractErrors = [ "name": "UUPSUnauthorizedCallContext", "type": "error" }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, { "inputs": [ { @@ -197,6 +268,16 @@ export const contractErrors = [ "name": "ERC20InvalidReceiver", "type": "error" }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", diff --git a/contracts/abi/IPoints.json b/contracts/abi/IPoints.json new file mode 100644 index 0000000..007ab10 --- /dev/null +++ b/contracts/abi/IPoints.json @@ -0,0 +1,229 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "finalized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/contracts/abi/IPoints.ts b/contracts/abi/IPoints.ts new file mode 100644 index 0000000..d756286 --- /dev/null +++ b/contracts/abi/IPoints.ts @@ -0,0 +1,229 @@ +export const IPointsAbi = [ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "finalized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/contracts/abi/IPointsHook.json b/contracts/abi/IPointsHook.json new file mode 100644 index 0000000..75c7ef7 --- /dev/null +++ b/contracts/abi/IPointsHook.json @@ -0,0 +1,53 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "takerFee", + "type": "uint256" + } + ], + "name": "onFill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "onLiquidation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/contracts/abi/IPointsHook.ts b/contracts/abi/IPointsHook.ts new file mode 100644 index 0000000..633ab56 --- /dev/null +++ b/contracts/abi/IPointsHook.ts @@ -0,0 +1,53 @@ +export const IPointsHookAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "takerFee", + "type": "uint256" + } + ], + "name": "onFill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "onLiquidation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/contracts/abi/Points.json b/contracts/abi/Points.json new file mode 100644 index 0000000..5db6974 --- /dev/null +++ b/contracts/abi/Points.json @@ -0,0 +1,649 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "AlreadyFinalized", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, + { + "inputs": [], + "name": "TransferNotAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "Finalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINTER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TRANSFER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "finalize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "finalized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/contracts/abi/Points.ts b/contracts/abi/Points.ts new file mode 100644 index 0000000..bc82508 --- /dev/null +++ b/contracts/abi/Points.ts @@ -0,0 +1,649 @@ +export const PointsAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "AlreadyFinalized", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, + { + "inputs": [], + "name": "TransferNotAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "Finalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINTER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TRANSFER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "finalize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "finalized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/contracts/abi/PointsHook.json b/contracts/abi/PointsHook.json new file mode 100644 index 0000000..11ecfff --- /dev/null +++ b/contracts/abi/PointsHook.json @@ -0,0 +1,835 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_points", + "type": "address" + }, + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_keeperPoints", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReferrer", + "type": "error" + }, + { + "inputs": [], + "name": "ReferrerAlreadySet", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "accountCap", + "type": "uint256" + } + ], + "name": "AccountCapSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isMaker", + "type": "bool" + } + ], + "name": "FillPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "KeeperPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "keeperPoints", + "type": "uint256" + } + ], + "name": "KeeperPointsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "stepBps", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxBps", + "type": "uint256" + } + ], + "name": "LoyaltySet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "minFee", + "type": "uint256" + } + ], + "name": "MinFeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "referralBps", + "type": "uint256" + } + ], + "name": "ReferralBpsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "referrer", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "referee", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ReferralPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "referee", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "referrer", + "type": "address" + } + ], + "name": "ReferrerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "wMaker", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wTaker", + "type": "uint256" + } + ], + "name": "WeightsSet", + "type": "event" + }, + { + "inputs": [], + "name": "BPS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "HOOK_CALLER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WEIGHT_SCALE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "accountCap", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "earned", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "keeperPoints", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "lastActivityDay", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "loyaltyMaxBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "loyaltyStepBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "takerFee", + "type": "uint256" + } + ], + "name": "onFill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "onLiquidation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "points", + "outputs": [ + { + "internalType": "contract IPoints", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "referralBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "referrerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_accountCap", + "type": "uint256" + } + ], + "name": "setAccountCap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_keeperPoints", + "type": "uint256" + } + ], + "name": "setKeeperPoints", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_stepBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxBps", + "type": "uint256" + } + ], + "name": "setLoyalty", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minFee", + "type": "uint256" + } + ], + "name": "setMinFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_referralBps", + "type": "uint256" + } + ], + "name": "setReferralBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "referrer", + "type": "address" + } + ], + "name": "setReferrer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + } + ], + "name": "setWeights", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "streakDays", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wMaker", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wTaker", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/contracts/abi/PointsHook.ts b/contracts/abi/PointsHook.ts new file mode 100644 index 0000000..02a05b9 --- /dev/null +++ b/contracts/abi/PointsHook.ts @@ -0,0 +1,835 @@ +export const PointsHookAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "_points", + "type": "address" + }, + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_keeperPoints", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReferrer", + "type": "error" + }, + { + "inputs": [], + "name": "ReferrerAlreadySet", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "accountCap", + "type": "uint256" + } + ], + "name": "AccountCapSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isMaker", + "type": "bool" + } + ], + "name": "FillPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "KeeperPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "keeperPoints", + "type": "uint256" + } + ], + "name": "KeeperPointsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "stepBps", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxBps", + "type": "uint256" + } + ], + "name": "LoyaltySet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "minFee", + "type": "uint256" + } + ], + "name": "MinFeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "referralBps", + "type": "uint256" + } + ], + "name": "ReferralBpsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "referrer", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "referee", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ReferralPointsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "referee", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "referrer", + "type": "address" + } + ], + "name": "ReferrerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "wMaker", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wTaker", + "type": "uint256" + } + ], + "name": "WeightsSet", + "type": "event" + }, + { + "inputs": [], + "name": "BPS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "HOOK_CALLER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WEIGHT_SCALE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "accountCap", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "earned", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "keeperPoints", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "lastActivityDay", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "loyaltyMaxBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "loyaltyStepBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "takerFee", + "type": "uint256" + } + ], + "name": "onFill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "onLiquidation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "points", + "outputs": [ + { + "internalType": "contract IPoints", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "referralBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "referrerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_accountCap", + "type": "uint256" + } + ], + "name": "setAccountCap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_keeperPoints", + "type": "uint256" + } + ], + "name": "setKeeperPoints", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_stepBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxBps", + "type": "uint256" + } + ], + "name": "setLoyalty", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minFee", + "type": "uint256" + } + ], + "name": "setMinFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_referralBps", + "type": "uint256" + } + ], + "name": "setReferralBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "referrer", + "type": "address" + } + ], + "name": "setReferrer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + } + ], + "name": "setWeights", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "streakDays", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wMaker", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wTaker", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] as const; diff --git a/contracts/abi/PointsRedeemer.json b/contracts/abi/PointsRedeemer.json new file mode 100644 index 0000000..84d47be --- /dev/null +++ b/contracts/abi/PointsRedeemer.json @@ -0,0 +1,344 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_points", + "type": "address" + }, + { + "internalType": "address", + "name": "_gov", + "type": "address" + }, + { + "internalType": "address", + "name": "_escrow", + "type": "address" + }, + { + "internalType": "address", + "name": "_owner", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "NotFinalized", + "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": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "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": "uint256", + "name": "govPool", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalPointsSnapshot", + "type": "uint256" + } + ], + "name": "RedemptionEnabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "pointsBurned", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "govAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "escrowAmount", + "type": "uint256" + } + ], + "name": "Swapped", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pool", + "type": "uint256" + } + ], + "name": "enableRedemption", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "enabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "escrow", + "outputs": [ + { + "internalType": "contract IVestingEscrow", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "gov", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "govPool", + "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": "points", + "outputs": [ + { + "internalType": "contract IPoints", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "previewSwap", + "outputs": [ + { + "internalType": "uint256", + "name": "govAmount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "recoverGov", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "swap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalPointsSnapshot", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/contracts/abi/PointsRedeemer.ts b/contracts/abi/PointsRedeemer.ts new file mode 100644 index 0000000..c8a6c56 --- /dev/null +++ b/contracts/abi/PointsRedeemer.ts @@ -0,0 +1,344 @@ +export const PointsRedeemerAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "_points", + "type": "address" + }, + { + "internalType": "address", + "name": "_gov", + "type": "address" + }, + { + "internalType": "address", + "name": "_escrow", + "type": "address" + }, + { + "internalType": "address", + "name": "_owner", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "NotFinalized", + "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": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "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": "uint256", + "name": "govPool", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalPointsSnapshot", + "type": "uint256" + } + ], + "name": "RedemptionEnabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "pointsBurned", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "govAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "escrowAmount", + "type": "uint256" + } + ], + "name": "Swapped", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pool", + "type": "uint256" + } + ], + "name": "enableRedemption", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "enabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "escrow", + "outputs": [ + { + "internalType": "contract IVestingEscrow", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "gov", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "govPool", + "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": "points", + "outputs": [ + { + "internalType": "contract IPoints", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "previewSwap", + "outputs": [ + { + "internalType": "uint256", + "name": "govAmount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "recoverGov", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "swap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalPointsSnapshot", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/contracts/contracts/Points.sol b/contracts/contracts/Points.sol new file mode 100644 index 0000000..179dedd --- /dev/null +++ b/contracts/contracts/Points.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {IPoints} from "./interfaces/IPoints.sol"; + +/// @title POINTS — Non-transferable rewards ledger +/// @notice Canonical on-chain balance for the points program. It exposes the read +/// side of the ERC20 interface (`name`/`symbol`/`decimals`/`balanceOf`/ +/// `totalSupply`) and emits standard `Transfer` events on mint/burn so +/// wallets and the leaderboard subgraph can track balances — but it is a +/// pure ledger, NOT a movable token: +/// - there are no allowances; `approve` is disabled, +/// - `transfer` / `transferFrom` always revert, +/// - the only state changes are `mint` (attribution, `MINTER_ROLE`) and +/// `burn` (redemption, `BURNER_ROLE`). +/// +/// Non-upgradeable by design. Lifecycle: minting is open for the whole +/// program window; `finalize()` permanently freezes minting (fixing +/// `totalSupply`) so redemption can run against a stable denominator. +contract Points is IERC20, IERC20Metadata, IPoints, AccessControl { + /// @notice Role allowed to mint POINTS (granted to `PointsHook`). + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @notice Role allowed to burn POINTS (granted to `PointsRedeemer`). + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + + string private constant _NAME = "Hashrate Points"; + string private constant _SYMBOL = "HP"; + uint8 private constant _DECIMALS = 6; + + uint256 private _totalSupply; + mapping(address => uint256) private _balances; + + /// @notice True once `finalize()` has frozen minting. Irreversible. + bool public override finalized; + + error TransfersDisabled(); + error MintingFinalized(); + error InsufficientBalance(); + error ZeroAddress(); + + event Finalized(); + + /// @dev Reverts once minting has been permanently frozen via `finalize()`. + modifier notFinalized() { + if (finalized) revert MintingFinalized(); + _; + } + + /// @param admin Receives `DEFAULT_ADMIN_ROLE` (mint/burn role grants + finalize). + constructor(address admin) { + if (admin == address(0)) revert ZeroAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + // ── ERC20 metadata / views ────────────────────────────────────────────── + + function name() external pure override returns (string memory) { + return _NAME; + } + + function symbol() external pure override returns (string memory) { + return _SYMBOL; + } + + /// @notice POINTS uses 6 decimals to match the GOV governance token. + function decimals() external pure override returns (uint8) { + return _DECIMALS; + } + + function totalSupply() external view override(IERC20, IPoints) returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) external view override(IERC20, IPoints) returns (uint256) { + return _balances[account]; + } + + /// @notice Always zero — POINTS has no allowance model. + function allowance(address, address) external pure override returns (uint256) { + return 0; + } + + // ── Disabled transfer surface ───────────────────────────────────────────── + + function approve(address, uint256) external pure override returns (bool) { + revert TransfersDisabled(); + } + + function transfer(address, uint256) external pure override returns (bool) { + revert TransfersDisabled(); + } + + function transferFrom(address, address, uint256) external pure override returns (bool) { + revert TransfersDisabled(); + } + + // ── Mint / burn ──────────────────────────────────────────────────────────── + + /// @inheritdoc IPoints + function mint(address to, uint256 amount) external override onlyRole(MINTER_ROLE) notFinalized { + if (to == address(0)) revert ZeroAddress(); + _totalSupply += amount; + unchecked { + _balances[to] += amount; + } + emit Transfer(address(0), to, amount); + } + + /// @inheritdoc IPoints + function burn(address from, uint256 amount) external override onlyRole(BURNER_ROLE) { + uint256 bal = _balances[from]; + if (bal < amount) revert InsufficientBalance(); + unchecked { + _balances[from] = bal - amount; + _totalSupply -= amount; + } + emit Transfer(from, address(0), amount); + } + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /// @notice Permanently freeze minting and open redemption. Admin-only, one-way. + function finalize() external onlyRole(DEFAULT_ADMIN_ROLE) notFinalized { + finalized = true; + emit Finalized(); + } +} diff --git a/contracts/contracts/PointsHook.sol b/contracts/contracts/PointsHook.sol new file mode 100644 index 0000000..e940239 --- /dev/null +++ b/contracts/contracts/PointsHook.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {IPointsHook} from "./interfaces/IPointsHook.sol"; +import {IPoints} from "./interfaces/IPoints.sol"; + +/// @title PointsHook — Points accrual logic for the perps + futures venues +/// @notice Non-upgradeable, plain deploy. Holds `MINTER_ROLE` on the POINTS token and +/// contains all of the points math and tunable weights. Not a fund-holding +/// contract. +/// +/// Retuning the formula is done by deploying a NEW `PointsHook` and pointing +/// each venue at it via `setHook()`, rather than upgrading — the hook is +/// designed to be replaced, not proxied. +/// +/// Anti-gaming defenses live here and at the venue: +/// - self-match exclusion (`maker == taker` mints nothing), +/// - per-side minimum fee threshold (dust trades earn nothing), +/// - the positive-fees invariant enforced by the venue config. +contract PointsHook is IPointsHook, AccessControl { + /// @notice Granted only to the venue contracts allowed to drive accrual. + bytes32 public constant HOOK_CALLER_ROLE = keccak256("HOOK_CALLER_ROLE"); + + /// @dev Fixed-point scale for the maker/taker weights (1e18 == 1 POINT per notional unit). + uint256 public constant WEIGHT_SCALE = 1e18; + + /// @notice The POINTS token this hook mints. + IPoints public immutable points; + + // ── Tunable parameters ───────────────────────────────────────────────────── + + /// @notice Maker weight (WAD). `points = notional * wMaker / WEIGHT_SCALE`. + uint256 public wMaker; + /// @notice Taker weight (WAD). Set `wMaker > wTaker` to bias toward liquidity. + uint256 public wTaker; + /// @notice Flat POINTS minted to a keeper per liquidation (POINTS decimals). + uint256 public keeperPoints; + /// @notice Minimum fee (collateral decimals) a side must pay to earn on a fill. + uint256 public minFee; + + // ── Errors / events ───────────────────────────────────────────────────────── + + error ZeroAddress(); + + event WeightsSet(uint256 wMaker, uint256 wTaker); + event KeeperPointsSet(uint256 keeperPoints); + event MinFeeSet(uint256 minFee); + event FillPointsMinted(address indexed account, uint256 amount, bool isMaker); + event KeeperPointsMinted(address indexed liquidator, uint256 amount); + + /// @param _points The POINTS token (this hook must be set as its `minter`). + /// @param admin Receives `DEFAULT_ADMIN_ROLE` (parameter tuning + role grants). + /// @param _wMaker Initial maker weight (WAD). + /// @param _wTaker Initial taker weight (WAD). + /// @param _keeperPoints Initial flat keeper reward (POINTS decimals). + constructor( + address _points, + address admin, + uint256 _wMaker, + uint256 _wTaker, + uint256 _keeperPoints + ) { + if (_points == address(0) || admin == address(0)) revert ZeroAddress(); + points = IPoints(_points); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + + wMaker = _wMaker; + wTaker = _wTaker; + keeperPoints = _keeperPoints; + emit WeightsSet(_wMaker, _wTaker); + emit KeeperPointsSet(_keeperPoints); + } + + // ── Venue entry points ────────────────────────────────────────────────────── + + /// @inheritdoc IPointsHook + function onFill(address maker, address taker, uint256 notional, int256 makerFee, uint256 takerFee) + external + override + onlyRole(HOOK_CALLER_ROLE) + { + // Self-match exclusion: a wallet trading with itself earns nothing. + if (maker == taker) return; + + // Taker side. + if (takerFee >= minFee) { + uint256 amount = (notional * wTaker) / WEIGHT_SCALE; + if (amount > 0) { + points.mint(taker, amount); + emit FillPointsMinted(taker, amount, false); + } + } + + // Maker side. A negative makerFee (rebate) earns nothing and violates the + // positive-fees invariant the program runs under. + if (makerFee > 0 && uint256(makerFee) >= minFee) { + uint256 amount = (notional * wMaker) / WEIGHT_SCALE; + if (amount > 0) { + points.mint(maker, amount); + emit FillPointsMinted(maker, amount, true); + } + } + } + + /// @inheritdoc IPointsHook + function onLiquidation(address liquidator, uint256 /* fee */ ) + external + override + onlyRole(HOOK_CALLER_ROLE) + { + uint256 amount = keeperPoints; + if (amount > 0) { + points.mint(liquidator, amount); + emit KeeperPointsMinted(liquidator, amount); + } + } + + // ── Admin: parameter tuning ───────────────────────────────────────────────── + + function setWeights(uint256 _wMaker, uint256 _wTaker) external onlyRole(DEFAULT_ADMIN_ROLE) { + wMaker = _wMaker; + wTaker = _wTaker; + emit WeightsSet(_wMaker, _wTaker); + } + + function setKeeperPoints(uint256 _keeperPoints) external onlyRole(DEFAULT_ADMIN_ROLE) { + keeperPoints = _keeperPoints; + emit KeeperPointsSet(_keeperPoints); + } + + function setMinFee(uint256 _minFee) external onlyRole(DEFAULT_ADMIN_ROLE) { + minFee = _minFee; + emit MinFeeSet(_minFee); + } +} diff --git a/contracts/contracts/PointsRedeemer.sol b/contracts/contracts/PointsRedeemer.sol new file mode 100644 index 0000000..32378c8 --- /dev/null +++ b/contracts/contracts/PointsRedeemer.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IPoints} from "./interfaces/IPoints.sol"; + +interface IVestingEscrow { + function lockFor(address user, uint256 amount) external; +} + +/// @title PointsRedeemer — Converts POINTS into GOV after the program ends +/// @notice Funded from a discretionary, treasury-supplied GOV pool (no new minting). +/// Redemption opens only after POINTS minting has been `finalize()`d, so the +/// total points denominator is fixed. Holds `BURNER_ROLE` on POINTS: a swap is +/// simply burning the caller's balance and paying out the corresponding GOV — +/// there is no transfer or `approve()` step because POINTS cannot move. +/// +/// Payout is pro-rata against a snapshot taken when redemption is enabled: +/// `userGOV = govPool * userPoints / totalPointsSnapshot` +/// and is split 50/50 between liquid GOV and a `VestingEscrow.lockFor` position +/// (180-day cliff + 90-day linear vest, with the relock bonus available), +/// reusing the governance-token `TokenMigration` pattern. +contract PointsRedeemer is Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @notice The POINTS token being redeemed (pulled and burned). + IPoints public immutable points; + /// @notice The GOV governance token paid out. + IERC20 public immutable gov; + /// @notice Vesting escrow that receives the locked half of each payout. + IVestingEscrow public immutable escrow; + + /// @notice True once redemption has been enabled by the owner. + bool public enabled; + /// @notice Total GOV available to distribute across all redeemers. + uint256 public govPool; + /// @notice `points.totalSupply()` captured at enable time; fixed payout denominator. + uint256 public totalPointsSnapshot; + + error AlreadyEnabled(); + error NotEnabled(); + error NotFinalized(); + error NoPoints(); + error EmptyPool(); + error InsufficientGov(); + error ZeroAddress(); + + event RedemptionEnabled(uint256 govPool, uint256 totalPointsSnapshot); + event Swapped( + address indexed user, uint256 pointsBurned, uint256 govAmount, uint256 liquidAmount, uint256 escrowAmount + ); + + constructor(address _points, address _gov, address _escrow, address _owner) Ownable(_owner) { + if (_points == address(0) || _gov == address(0) || _escrow == address(0)) revert ZeroAddress(); + points = IPoints(_points); + gov = IERC20(_gov); + escrow = IVestingEscrow(_escrow); + } + + /// @notice Open redemption against a fixed GOV pool. Requires POINTS minting to be + /// finalized and the pool's GOV to already be held by this contract. + /// @param pool The total GOV to distribute pro-rata. May be larger than strictly + /// needed; any remainder is recoverable by the owner. + function enableRedemption(uint256 pool) external onlyOwner { + if (enabled) revert AlreadyEnabled(); + if (!points.finalized()) revert NotFinalized(); + if (pool == 0) revert EmptyPool(); + if (gov.balanceOf(address(this)) < pool) revert InsufficientGov(); + + uint256 supply = points.totalSupply(); + if (supply == 0) revert NoPoints(); + + enabled = true; + govPool = pool; + totalPointsSnapshot = supply; + emit RedemptionEnabled(pool, supply); + } + + /// @notice Redeem the caller's entire POINTS balance for GOV. No `approve()` is + /// possible or needed: this contract holds `BURNER_ROLE` and burns the + /// caller's balance directly. + function swap() external nonReentrant { + if (!enabled) revert NotEnabled(); + + uint256 bal = points.balanceOf(_msgSender()); + if (bal == 0) revert NoPoints(); + + uint256 govAmount = (govPool * bal) / totalPointsSnapshot; + uint256 liquidAmount = govAmount / 2; + uint256 escrowAmount = govAmount - liquidAmount; + + // Burn the caller's POINTS (redemption == burn). + points.burn(_msgSender(), bal); + + if (liquidAmount > 0) { + gov.safeTransfer(_msgSender(), liquidAmount); + } + if (escrowAmount > 0) { + gov.safeTransfer(address(escrow), escrowAmount); + escrow.lockFor(_msgSender(), escrowAmount); + } + + emit Swapped(_msgSender(), bal, govAmount, liquidAmount, escrowAmount); + } + + /// @notice Quote the GOV payout for `user` at the current snapshot. Zero until enabled. + function previewSwap(address user) external view returns (uint256 govAmount) { + if (!enabled) return 0; + uint256 bal = points.balanceOf(user); + return (govPool * bal) / totalPointsSnapshot; + } + + /// @notice Recover GOV left over after redemption (e.g. rounding dust or an oversized pool). + function recoverGov(address to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + gov.safeTransfer(to, amount); + } +} diff --git a/contracts/contracts/interfaces/IPoints.sol b/contracts/contracts/interfaces/IPoints.sol new file mode 100644 index 0000000..04be636 --- /dev/null +++ b/contracts/contracts/interfaces/IPoints.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IPoints — Mint/burn + balance surface the hook and redeemer rely on +/// @notice The canonical POINTS token (6 decimals) is a non-transferable ledger: +/// attributing points is a `mint`, redeeming them is a `burn`. Both are +/// role-gated; there are no user-to-user transfers and no allowances. +interface IPoints { + /// @notice Mint `amount` POINTS to `to`. Restricted to `MINTER_ROLE`; reverts once finalized. + function mint(address to, uint256 amount) external; + + /// @notice Burn `amount` POINTS from `from`. Restricted to `BURNER_ROLE` (the redeemer). + function burn(address from, uint256 amount) external; + + /// @notice Whether minting has been permanently frozen via `finalize()`. + function finalized() external view returns (bool); + + /// @notice Current POINTS balance of `account`. + function balanceOf(address account) external view returns (uint256); + + /// @notice Total POINTS in circulation. + function totalSupply() external view returns (uint256); +} diff --git a/contracts/contracts/interfaces/IPointsHook.sol b/contracts/contracts/interfaces/IPointsHook.sol new file mode 100644 index 0000000..9fe0b2d --- /dev/null +++ b/contracts/contracts/interfaces/IPointsHook.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IPointsHook — Venue → points integration surface +/// @notice The two CLOB venues (perps `HashPowerPerpsDEX`, `Futures`) call into a +/// contract implementing this interface from their fill and liquidation +/// paths. The venues import ONLY this interface from collateral-margin and +/// always wrap the calls in `try/catch` so a points-side revert can never +/// block a trade or a liquidation. +interface IPointsHook { + /// @notice Called once per matched maker/taker pair at fill time. + /// @dev A single taker `createOrder` can walk the book and match against N + /// resting maker orders, producing N `onFill` calls in one transaction. + /// @param maker The resting (maker) side of the match. + /// @param taker The aggressing (taker) side of the match. + /// @param notional Trade notional in collateral-token decimals (e.g. 1e6 == $1). + /// @param makerFee Maker fee actually paid (collateral decimals, signed; a + /// rebate would be negative — disallowed while points are live). + /// @param takerFee Taker fee actually paid (collateral decimals). + function onFill(address maker, address taker, uint256 notional, int256 makerFee, uint256 takerFee) external; + + /// @notice Called when a keeper executes a liquidation on either venue. + /// @param liquidator The address that executed the liquidation. + /// @param fee The liquidator fee earned (collateral decimals); informational. + function onLiquidation(address liquidator, uint256 fee) external; +} diff --git a/contracts/contracts/mocks/GovTokenMock.sol b/contracts/contracts/mocks/GovTokenMock.sol new file mode 100644 index 0000000..3ed5a46 --- /dev/null +++ b/contracts/contracts/mocks/GovTokenMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Minimal GOV stand-in for redeemer tests: 6 decimals, freely mintable. +contract GovTokenMock is ERC20 { + constructor() ERC20("Titan Governance", "GOV") { + _mint(msg.sender, 50_000_000 * 10 ** 6); + } + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/contracts/contracts/mocks/VestingEscrowMock.sol b/contracts/contracts/mocks/VestingEscrowMock.sol new file mode 100644 index 0000000..9cb4733 --- /dev/null +++ b/contracts/contracts/mocks/VestingEscrowMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Records `lockFor` calls so redeemer tests can assert the escrowed half of a +/// payout, mirroring the real `VestingEscrow.lockFor` interface. +contract VestingEscrowMock { + mapping(address => uint256) public lockedOf; + uint256 public totalLocked; + + event Locked(address indexed user, uint256 amount); + + function lockFor(address user, uint256 amount) external { + lockedOf[user] += amount; + totalLocked += amount; + emit Locked(user, amount); + } +} diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 6cb9546..f390b5a 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -14,6 +14,11 @@ export default defineConfig({ "ICollateralVault", "PortfolioMarginEngine", "IPortfolioMarginEngine", + "Points", + "IPoints", + "PointsHook", + "IPointsHook", + "PointsRedeemer", ], }, paths: { diff --git a/contracts/package.json b/contracts/package.json index acf6ab0..4224946 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -12,6 +12,7 @@ "clean": "rm -rf abi artifacts cache", "deploy:vault": "hardhat run scripts/deploy-collateral-vault.ts", "deploy:pme": "hardhat run scripts/deploy-portfolio-margin-engine.ts", + "deploy:points": "hardhat run scripts/deploy-points.ts", "upgrade:vault": "hardhat run scripts/update-collateral-vault.ts", "upgrade:pme": "hardhat run scripts/update-portfolio-margin-engine.ts" }, diff --git a/contracts/scripts/deploy-points.ts b/contracts/scripts/deploy-points.ts new file mode 100644 index 0000000..9fb36c3 --- /dev/null +++ b/contracts/scripts/deploy-points.ts @@ -0,0 +1,161 @@ +import fs from "node:fs"; +import hre from "hardhat"; +import { readOptionalAddress, readOptionalBigInt } from "../lib/env.ts"; +import { writeAndWait } from "../lib/writeContract.ts"; +import { verifyContract } from "../lib/verify.ts"; +import { addrUrl, txUrl } from "../lib/explorer.ts"; +import { logInfo, logPrompt, logStep, logSuccess, logTitle } from "../lib/log.ts"; + +/** 1.5 POINTS per notional unit (maker), biasing toward liquidity. */ +const DEFAULT_W_MAKER = 1_500_000_000_000_000_000n; +/** 1 POINT per notional unit (taker). */ +const DEFAULT_W_TAKER = 1_000_000_000_000_000_000n; +/** 5 POINTS (6 decimals) per liquidation. */ +const DEFAULT_KEEPER_POINTS = 5_000_000n; + +async function main() { + logTitle("Points System Deployment"); + + const { viem } = await hre.network.getOrCreate(); + + const wMaker = readOptionalBigInt("POINTS_W_MAKER") ?? DEFAULT_W_MAKER; + const wTaker = readOptionalBigInt("POINTS_W_TAKER") ?? DEFAULT_W_TAKER; + const keeperPoints = readOptionalBigInt("POINTS_KEEPER") ?? DEFAULT_KEEPER_POINTS; + const minFee = readOptionalBigInt("POINTS_MIN_FEE"); + + const PERPS_DEX_ADDRESS = readOptionalAddress("PERPS_DEX_ADDRESS"); + const FUTURES_ADDRESS = readOptionalAddress("FUTURES_ADDRESS"); + const GOV_TOKEN_ADDRESS = readOptionalAddress("GOV_TOKEN_ADDRESS"); + const VESTING_ESCROW_ADDRESS = readOptionalAddress("VESTING_ESCROW_ADDRESS"); + const SAFE_OWNER_ADDRESS = readOptionalAddress("SAFE_OWNER_ADDRESS"); + + const [deployer] = await viem.getWalletClients(); + const pc = await viem.getPublicClient(); + const admin = deployer.account.address; + logInfo("deployer", { Address: addrUrl(pc, admin) }); + + logInfo("hook parameters", { + wMaker: wMaker.toString(), + wTaker: wTaker.toString(), + keeperPoints: keeperPoints.toString(), + minFee: minFee?.toString() ?? "(0)", + }); + logInfo("venues (granted HOOK_CALLER_ROLE if set)", { + Perps: PERPS_DEX_ADDRESS ?? "(none)", + Futures: FUTURES_ADDRESS ?? "(none)", + }); + logInfo("redeemer (deployed if both set)", { + GOV: GOV_TOKEN_ADDRESS ?? "(none)", + VestingEscrow: VESTING_ESCROW_ADDRESS ?? "(none)", + }); + if (SAFE_OWNER_ADDRESS) logInfo("ownership", { willTransferTo: SAFE_OWNER_ADDRESS }); + + await logPrompt("Review the configuration above. Proceed with deployment?"); + + // ── 1. POINTS token ─────────────────────────────────────────────────────── + logInfo("Deploy Points", { admin }); + await logPrompt("Proceed?"); + const points = await viem.deployContract("Points", [admin], { confirmations: 5 }); + logStep("Deployed", addrUrl(pc, points.address)); + await verifyContract(points.address, [admin]); + + // ── 2. PointsHook ───────────────────────────────────────────────────────── + logInfo("Deploy PointsHook", { points: points.address }); + await logPrompt("Proceed?"); + const hookArgs = [points.address, admin, wMaker, wTaker, keeperPoints] as const; + const hook = await viem.deployContract("PointsHook", hookArgs, { confirmations: 5 }); + logStep("Deployed", addrUrl(pc, hook.address)); + await verifyContract(hook.address, [...hookArgs]); + + // ── 3. Grant MINTER_ROLE to the hook ──────────────────────────────────────── + const MINTER_ROLE = await points.read.MINTER_ROLE(); + logInfo("Points.grantRole(MINTER_ROLE, hook)", { hook: hook.address }); + await logPrompt("Proceed?"); + { + const sim = await points.simulate.grantRole([MINTER_ROLE, hook.address]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + + // ── 4. Optional hook parameter tuning ─────────────────────────────────────── + if (minFee !== undefined) { + const sim = await hook.simulate.setMinFee([minFee]); + const receipt = await writeAndWait(deployer, sim); + logStep(`hook.setMinFee(${minFee})`, txUrl(pc, receipt.transactionHash)); + } + // ── 5. Grant HOOK_CALLER_ROLE to the venues ───────────────────────────────── + const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); + for (const [label, addr] of [ + ["perps", PERPS_DEX_ADDRESS], + ["futures", FUTURES_ADDRESS], + ] as const) { + if (!addr) continue; + logInfo(`hook.grantRole(HOOK_CALLER_ROLE, ${label})`, { venue: addr }); + await logPrompt("Proceed?"); + const sim = await hook.simulate.grantRole([HOOK_CALLER_ROLE, addr]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + + // ── 6. Optional PointsRedeemer ────────────────────────────────────────────── + let redeemerAddress: string | undefined; + if (GOV_TOKEN_ADDRESS && VESTING_ESCROW_ADDRESS) { + const redeemerOwner = SAFE_OWNER_ADDRESS ?? admin; + logInfo("Deploy PointsRedeemer", { + gov: GOV_TOKEN_ADDRESS, + escrow: VESTING_ESCROW_ADDRESS, + owner: redeemerOwner, + }); + await logPrompt("Proceed?"); + const redeemerArgs = [points.address, GOV_TOKEN_ADDRESS, VESTING_ESCROW_ADDRESS, redeemerOwner] as const; + const redeemer = await viem.deployContract("PointsRedeemer", redeemerArgs, { confirmations: 5 }); + redeemerAddress = redeemer.address; + logStep("Deployed", addrUrl(pc, redeemer.address)); + await verifyContract(redeemer.address, [...redeemerArgs]); + + const BURNER_ROLE = await points.read.BURNER_ROLE(); + logInfo("Points.grantRole(BURNER_ROLE, redeemer)", { redeemer: redeemer.address }); + await logPrompt("Proceed?"); + const sim = await points.simulate.grantRole([BURNER_ROLE, redeemer.address]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + + // ── 7. Transfer POINTS admin to the Safe (optional) ───────────────────────── + if (SAFE_OWNER_ADDRESS) { + const ADMIN_ROLE = await points.read.DEFAULT_ADMIN_ROLE(); + logInfo("Points: grant admin to Safe, then renounce deployer admin", { + safe: SAFE_OWNER_ADDRESS, + }); + await logPrompt("Proceed?"); + let sim = await points.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); + let receipt = await writeAndWait(deployer, sim); + logStep("granted admin to Safe", txUrl(pc, receipt.transactionHash)); + + sim = await hook.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); + receipt = await writeAndWait(deployer, sim); + logStep("granted hook admin to Safe", txUrl(pc, receipt.transactionHash)); + } + + // ── Summary ───────────────────────────────────────────────────────────────── + logInfo("addresses", { + Points: points.address, + PointsHook: hook.address, + PointsRedeemer: redeemerAddress ?? "(not deployed)", + }); + logSuccess(`Points ${points.address} / Hook ${hook.address}`); + + fs.writeFileSync( + "points-addr.tmp", + JSON.stringify( + { points: points.address, hook: hook.address, redeemer: redeemerAddress ?? null }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/contracts/tests/points.test.ts b/contracts/tests/points.test.ts new file mode 100644 index 0000000..b3a1123 --- /dev/null +++ b/contracts/tests/points.test.ts @@ -0,0 +1,164 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { zeroAddress } from "viem"; +import { network } from "hardhat"; +import { deployPointsFixture } from "./pointsFixtures.js"; + +const { viem, networkHelpers } = await network.connect(); + +const ONE_POINT = 1_000_000n; // 6 decimals + +/** Grant MINTER_ROLE to owner and mint `amount` to `to`. */ +async function mintTo( + fixture: Awaited>, + to: `0x${string}`, + amount: bigint, +) { + const { points, owner } = fixture; + const minterRole = await points.read.MINTER_ROLE(); + if (!(await points.read.hasRole([minterRole, owner.account.address]))) { + await points.write.grantRole([minterRole, owner.account.address], { account: owner.account }); + } + await points.write.mint([to, amount], { account: owner.account }); +} + +describe("Points", () => { + describe("metadata", () => { + it("uses HP symbol and 6 decimals", async () => { + const { points } = await networkHelpers.loadFixture(deployPointsFixture); + assert.equal(await points.read.name(), "Hashrate Points"); + assert.equal(await points.read.symbol(), "HP"); + assert.equal(await points.read.decimals(), 6); + }); + + it("grants admin role to the deployer-specified admin", async () => { + const { points, owner } = await networkHelpers.loadFixture(deployPointsFixture); + const adminRole = await points.read.DEFAULT_ADMIN_ROLE(); + assert.equal(await points.read.hasRole([adminRole, owner.account.address]), true); + }); + }); + + describe("minting", () => { + it("only MINTER_ROLE can mint", async () => { + const { points, alice } = await networkHelpers.loadFixture(deployPointsFixture); + await viem.assertions.revertWithCustomError( + points.write.mint([alice.account.address, ONE_POINT], { account: alice.account }), + points, + "AccessControlUnauthorizedAccount", + ); + }); + + it("mints, crediting balance and total supply", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + await mintTo(fx, fx.alice.account.address, ONE_POINT); + assert.equal(await fx.points.read.balanceOf([fx.alice.account.address]), ONE_POINT); + assert.equal(await fx.points.read.totalSupply(), ONE_POINT); + }); + }); + + describe("non-transferable", () => { + it("blocks transfer", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + await mintTo(fx, fx.alice.account.address, ONE_POINT); + await viem.assertions.revertWithCustomError( + fx.points.write.transfer([fx.bob.account.address, ONE_POINT], { account: fx.alice.account }), + fx.points, + "TransfersDisabled", + ); + }); + + it("blocks transferFrom even for the admin", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + await mintTo(fx, fx.alice.account.address, ONE_POINT); + await viem.assertions.revertWithCustomError( + fx.points.write.transferFrom([fx.alice.account.address, fx.bob.account.address, ONE_POINT], { + account: fx.owner.account, + }), + fx.points, + "TransfersDisabled", + ); + }); + + it("blocks approve and reports zero allowance", async () => { + const { points, alice, bob } = await networkHelpers.loadFixture(deployPointsFixture); + await viem.assertions.revertWithCustomError( + points.write.approve([bob.account.address, ONE_POINT], { account: alice.account }), + points, + "TransfersDisabled", + ); + assert.equal(await points.read.allowance([alice.account.address, bob.account.address]), 0n); + }); + }); + + describe("burning", () => { + it("only BURNER_ROLE can burn", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + await mintTo(fx, fx.alice.account.address, ONE_POINT); + await viem.assertions.revertWithCustomError( + fx.points.write.burn([fx.alice.account.address, ONE_POINT], { account: fx.alice.account }), + fx.points, + "AccessControlUnauthorizedAccount", + ); + }); + + it("burns from an account and reduces supply", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + const { points, owner, alice } = fx; + await mintTo(fx, alice.account.address, ONE_POINT); + const burnerRole = await points.read.BURNER_ROLE(); + await points.write.grantRole([burnerRole, owner.account.address], { account: owner.account }); + + await points.write.burn([alice.account.address, ONE_POINT], { account: owner.account }); + assert.equal(await points.read.balanceOf([alice.account.address]), 0n); + assert.equal(await points.read.totalSupply(), 0n); + }); + }); + + describe("finalize", () => { + it("only admin can finalize", async () => { + const { points, alice } = await networkHelpers.loadFixture(deployPointsFixture); + await viem.assertions.revertWithCustomError( + points.write.finalize({ account: alice.account }), + points, + "AccessControlUnauthorizedAccount", + ); + }); + + it("freezes minting after finalize", async () => { + const fx = await networkHelpers.loadFixture(deployPointsFixture); + const { points, owner, alice } = fx; + const minterRole = await points.read.MINTER_ROLE(); + await points.write.grantRole([minterRole, owner.account.address], { account: owner.account }); + + await viem.assertions.emit(points.write.finalize({ account: owner.account }), points, "Finalized"); + assert.equal(await points.read.finalized(), true); + + await viem.assertions.revertWithCustomError( + points.write.mint([alice.account.address, ONE_POINT], { account: owner.account }), + points, + "MintingFinalized", + ); + }); + + it("reverts on double finalize", async () => { + const { points, owner } = await networkHelpers.loadFixture(deployPointsFixture); + await points.write.finalize({ account: owner.account }); + await viem.assertions.revertWithCustomError( + points.write.finalize({ account: owner.account }), + points, + "MintingFinalized", + ); + }); + }); + + describe("constructor", () => { + it("rejects a zero admin", async () => { + const { points } = await networkHelpers.loadFixture(deployPointsFixture); + await viem.assertions.revertWithCustomError( + viem.deployContract("Points", [zeroAddress]), + points, + "ZeroAddress", + ); + }); + }); +}); diff --git a/contracts/tests/pointsFixtures.ts b/contracts/tests/pointsFixtures.ts new file mode 100644 index 0000000..8801961 --- /dev/null +++ b/contracts/tests/pointsFixtures.ts @@ -0,0 +1,76 @@ +import type { NetworkConnection } from "hardhat/types/network"; + +/** 1.5e18 — maker weight (1.5 POINTS per notional unit). */ +export const W_MAKER = 1_500_000_000_000_000_000n; +/** 1e18 — taker weight (1 POINT per notional unit). */ +export const W_TAKER = 1_000_000_000_000_000_000n; +/** 5 POINTS (6 decimals) flat per liquidation. */ +export const KEEPER_POINTS = 5_000_000n; +/** $1000 notional in collateral (6 decimals). */ +export const NOTIONAL = 1_000_000_000n; +/** 1 GOV / POINT helper amounts (6 decimals). */ +export const ONE_TOKEN = 1_000_000n; + +/** Deploy the bare POINTS token with `owner` as admin. */ +export async function deployPointsFixture(conn: NetworkConnection) { + const { viem } = conn; + const [owner, alice, bob, carol] = await viem.getWalletClients(); + const points = await viem.deployContract("Points", [owner.account.address]); + return { points, owner, alice, bob, carol }; +} + +/** + * POINTS + PointsHook wired together: + * - the hook is the POINTS `minter`, + * - `venue` wallet holds HOOK_CALLER_ROLE (stands in for a venue contract). + */ +export async function deployHookFixture(conn: NetworkConnection) { + const { viem } = conn; + const [owner, alice, bob, carol, venue, keeper] = await viem.getWalletClients(); + const points = await viem.deployContract("Points", [owner.account.address]); + const hook = await viem.deployContract("PointsHook", [ + points.address, + owner.account.address, + W_MAKER, + W_TAKER, + KEEPER_POINTS, + ]); + + const MINTER_ROLE = await points.read.MINTER_ROLE(); + const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); + await points.write.grantRole([MINTER_ROLE, hook.address], { account: owner.account }); + await hook.write.grantRole([HOOK_CALLER_ROLE, venue.account.address], { account: owner.account }); + + return { points, hook, owner, alice, bob, carol, venue, keeper, MINTER_ROLE, HOOK_CALLER_ROLE }; +} + +/** + * POINTS + GOV + escrow + PointsRedeemer, with balances minted to alice/bob and + * redemption left DISABLED (tests finalize + enable as needed). + * - the redeemer is the POINTS `burner`, + * - `owner` is the POINTS `minter` (mints test balances directly). + */ +export async function deployRedeemerFixture(conn: NetworkConnection) { + const { viem } = conn; + const [owner, alice, bob, carol] = await viem.getWalletClients(); + const points = await viem.deployContract("Points", [owner.account.address]); + const gov = await viem.deployContract("GovTokenMock", []); + const escrow = await viem.deployContract("VestingEscrowMock", []); + const redeemer = await viem.deployContract("PointsRedeemer", [ + points.address, + gov.address, + escrow.address, + owner.account.address, + ]); + + const MINTER_ROLE = await points.read.MINTER_ROLE(); + const BURNER_ROLE = await points.read.BURNER_ROLE(); + await points.write.grantRole([MINTER_ROLE, owner.account.address], { account: owner.account }); + await points.write.grantRole([BURNER_ROLE, redeemer.address], { account: owner.account }); + + return { points, gov, escrow, redeemer, owner, alice, bob, carol, MINTER_ROLE, BURNER_ROLE }; +} + +export type PointsFixture = Awaited>; +export type HookFixture = Awaited>; +export type RedeemerFixture = Awaited>; diff --git a/contracts/tests/pointsHook.test.ts b/contracts/tests/pointsHook.test.ts new file mode 100644 index 0000000..15f7c27 --- /dev/null +++ b/contracts/tests/pointsHook.test.ts @@ -0,0 +1,100 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress } from "viem"; +import { network } from "hardhat"; +import { + KEEPER_POINTS, + NOTIONAL, + W_MAKER, + W_TAKER, + deployHookFixture, +} from "./pointsFixtures.js"; + +const { viem, networkHelpers } = await network.connect(); + +/** Expected taker points for the fixture's NOTIONAL + W_TAKER. */ +const TAKER_PTS = (NOTIONAL * W_TAKER) / 10n ** 18n; // 1000 POINTS +/** Expected maker points for the fixture's NOTIONAL + W_MAKER. */ +const MAKER_PTS = (NOTIONAL * W_MAKER) / 10n ** 18n; // 1500 POINTS +const FEE = 1_000_000n; // 1 USDC, comfortably above any threshold + +describe("PointsHook", () => { + describe("authorization", () => { + it("rejects onFill from a non-venue caller", async () => { + const { hook, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); + await viem.assertions.revertWithCustomError( + hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + account: alice.account, + }), + hook, + "AccessControlUnauthorizedAccount", + ); + }); + + it("rejects onLiquidation from a non-venue caller", async () => { + const { hook, alice, keeper } = await networkHelpers.loadFixture(deployHookFixture); + await viem.assertions.revertWithCustomError( + hook.write.onLiquidation([keeper.account.address, FEE], { account: alice.account }), + hook, + "AccessControlUnauthorizedAccount", + ); + }); + }); + + describe("onFill accrual", () => { + it("mints weighted points to maker and taker", async () => { + const { hook, points, venue, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); + // alice = maker, bob = taker + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + account: venue.account, + }); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); + assert.equal(await points.read.balanceOf([bob.account.address]), TAKER_PTS); + }); + + it("skips minting on a self-match", async () => { + const { hook, points, venue, alice } = await networkHelpers.loadFixture(deployHookFixture); + await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE], { + account: venue.account, + }); + assert.equal(await points.read.balanceOf([alice.account.address]), 0n); + assert.equal(await points.read.totalSupply(), 0n); + }); + + it("does not reward a maker rebate (non-positive makerFee)", async () => { + const { hook, points, venue, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); + // makerFee = -1 (rebate): maker earns nothing, taker still earns. + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, -1n, FEE], { + account: venue.account, + }); + assert.equal(await points.read.balanceOf([alice.account.address]), 0n); + assert.equal(await points.read.balanceOf([bob.account.address]), TAKER_PTS); + }); + + it("enforces the minimum fee threshold per side", async () => { + const { hook, points, owner, venue, alice, bob } = + await networkHelpers.loadFixture(deployHookFixture); + await hook.write.setMinFee([FEE], { account: owner.account }); + + // taker pays below threshold, maker pays at threshold. + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE - 1n], { + account: venue.account, + }); + assert.equal(await points.read.balanceOf([bob.account.address]), 0n); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); + }); + }); + + describe("onLiquidation", () => { + it("mints flat keeper points", async () => { + const { hook, points, venue, keeper } = await networkHelpers.loadFixture(deployHookFixture); + await viem.assertions.emitWithArgs( + hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account }), + hook, + "KeeperPointsMinted", + [getAddress(keeper.account.address), KEEPER_POINTS], + ); + assert.equal(await points.read.balanceOf([keeper.account.address]), KEEPER_POINTS); + }); + }); +}); diff --git a/contracts/tests/pointsRedeemer.test.ts b/contracts/tests/pointsRedeemer.test.ts new file mode 100644 index 0000000..c653457 --- /dev/null +++ b/contracts/tests/pointsRedeemer.test.ts @@ -0,0 +1,158 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress } from "viem"; +import { network } from "hardhat"; +import { deployRedeemerFixture } from "./pointsFixtures.js"; + +const { viem, networkHelpers } = await network.connect(); + +const ALICE_PTS = 1_000_000_000n; // 1000 POINTS +const BOB_PTS = 3_000_000_000n; // 3000 POINTS +const POOL = 4_000_000_000n; // 4000 GOV + +/** Finalize POINTS, fund the redeemer, mint balances, and open redemption. */ +async function setupEnabled(fixture: Awaited>) { + const { points, gov, redeemer, owner, alice, bob } = fixture; + await points.write.mint([alice.account.address, ALICE_PTS], { account: owner.account }); + await points.write.mint([bob.account.address, BOB_PTS], { account: owner.account }); + await points.write.finalize({ account: owner.account }); + await gov.write.transfer([redeemer.address, POOL], { account: owner.account }); + await redeemer.write.enableRedemption([POOL], { account: owner.account }); +} + +describe("PointsRedeemer", () => { + describe("enableRedemption", () => { + it("reverts before POINTS is finalized", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + const { points, gov, redeemer, owner, alice } = fx; + await points.write.mint([alice.account.address, ALICE_PTS], { account: owner.account }); + await gov.write.transfer([redeemer.address, POOL], { account: owner.account }); + await viem.assertions.revertWithCustomError( + redeemer.write.enableRedemption([POOL], { account: owner.account }), + redeemer, + "NotFinalized", + ); + }); + + it("reverts when the pool exceeds the GOV held", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + const { points, redeemer, owner, alice } = fx; + await points.write.mint([alice.account.address, ALICE_PTS], { account: owner.account }); + await points.write.finalize({ account: owner.account }); + await viem.assertions.revertWithCustomError( + redeemer.write.enableRedemption([POOL], { account: owner.account }), + redeemer, + "InsufficientGov", + ); + }); + + it("snapshots pool and total points", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + assert.equal(await fx.redeemer.read.enabled(), true); + assert.equal(await fx.redeemer.read.govPool(), POOL); + assert.equal(await fx.redeemer.read.totalPointsSnapshot(), ALICE_PTS + BOB_PTS); + }); + + it("reverts on double enable", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + await viem.assertions.revertWithCustomError( + fx.redeemer.write.enableRedemption([POOL], { account: fx.owner.account }), + fx.redeemer, + "AlreadyEnabled", + ); + }); + }); + + describe("swap", () => { + it("reverts before redemption is enabled", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + const { points, redeemer, owner, alice } = fx; + await points.write.mint([alice.account.address, ALICE_PTS], { account: owner.account }); + await viem.assertions.revertWithCustomError( + redeemer.write.swap({ account: alice.account }), + redeemer, + "NotEnabled", + ); + }); + + it("pays pro-rata, splitting 50/50 liquid and escrow, with no approve", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + const { points, gov, escrow, redeemer, alice } = fx; + + const expectedGov = (POOL * ALICE_PTS) / (ALICE_PTS + BOB_PTS); // 1000 GOV + const liquid = expectedGov / 2n; + const escrowAmt = expectedGov - liquid; + + await viem.assertions.emitWithArgs( + redeemer.write.swap({ account: alice.account }), + redeemer, + "Swapped", + [getAddress(alice.account.address), ALICE_PTS, expectedGov, liquid, escrowAmt], + ); + + assert.equal(await gov.read.balanceOf([alice.account.address]), liquid); + assert.equal(await escrow.read.lockedOf([alice.account.address]), escrowAmt); + assert.equal(await points.read.balanceOf([alice.account.address]), 0n); + }); + + it("keeps the denominator fixed as balances burn down", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + const { gov, redeemer, alice, bob } = fx; + + await redeemer.write.swap({ account: alice.account }); + await redeemer.write.swap({ account: bob.account }); + + // Bob (3x Alice's points) gets 3x the GOV, denominator unchanged by Alice's burn. + const aliceGov = await gov.read.balanceOf([alice.account.address]); + const bobGov = await gov.read.balanceOf([bob.account.address]); + assert.equal(bobGov, aliceGov * 3n); + }); + + it("reverts when the caller holds no points", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + await viem.assertions.revertWithCustomError( + fx.redeemer.write.swap({ account: fx.carol.account }), + fx.redeemer, + "NoPoints", + ); + }); + }); + + describe("previewSwap", () => { + it("returns 0 before enable and the pro-rata amount after", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + assert.equal(await fx.redeemer.read.previewSwap([fx.alice.account.address]), 0n); + await setupEnabled(fx); + const expected = (POOL * ALICE_PTS) / (ALICE_PTS + BOB_PTS); + assert.equal(await fx.redeemer.read.previewSwap([fx.alice.account.address]), expected); + }); + }); + + describe("recoverGov", () => { + it("lets the owner sweep leftover GOV", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + const { gov, redeemer, owner, alice, carol } = fx; + await redeemer.write.swap({ account: alice.account }); + + const remaining = await gov.read.balanceOf([redeemer.address]); + await redeemer.write.recoverGov([carol.account.address, remaining], { account: owner.account }); + assert.equal(await gov.read.balanceOf([carol.account.address]), remaining); + }); + + it("blocks non-owner recovery", async () => { + const fx = await networkHelpers.loadFixture(deployRedeemerFixture); + await setupEnabled(fx); + await viem.assertions.revertWithCustomError( + fx.redeemer.write.recoverGov([fx.alice.account.address, 1n], { account: fx.alice.account }), + fx.redeemer, + "OwnableUnauthorizedAccount", + ); + }); + }); +}); diff --git a/docs/points-system-design.md b/docs/points-system-design.md new file mode 100644 index 0000000..83773f1 --- /dev/null +++ b/docs/points-system-design.md @@ -0,0 +1,234 @@ +# Points System Design Specification + +## Status + +Implemented. This document describes a points/rewards program for the perps and futures-marketplace venues, the on-chain contracts that power it, and the path from points to the GOV governance token. The contracts (`Points`, `PointsHook`, `PointsRedeemer`) and the points subgraph live in `collateral-margin`; the venue-side wiring lives in the `perps` and `futures-marketplace` repos. + +Several features sketched in early drafts were deliberately **deferred** to keep the first iteration minimal and hard to game: referral rewards, a loyalty/streak multiplier, and per-account caps. Their designs and the rationale for cutting them are recorded in [`points-system-improvements.md`](./points-system-improvements.md). This document describes what was actually built. + +## 1. Goals and context + +The goal is to bootstrap activity on two new on-chain CLOB venues by rewarding users with **points** that will later be convertible into the **GOV** governance token. Points are designed to: + +- Reward the activity that actually creates protocol value (matched, fee-generating volume), weighted to bootstrap liquidity. +- Live on-chain as a token, so the eventual conversion to GOV is a simple token operation rather than an off-chain reconciliation. +- Be non-transferable between users, so they cannot be sold on secondary points markets while the program runs. + +Reference points programs (Tensor, Blur, Blast, EigenLayer) and the broader analysis are summarized in Galaxy's [Crypto Points Programs](https://www.galaxy.com/insights/research/crypto-points-programs) report; the design choices below are informed by it, but deliberately diverge on one major axis: **points are accounted on-chain, not off-chain.** The rationale for that divergence is recorded in [Section 8](#8-accepted-tradeoffs). + +### What is being traded (for context) + +- **perps** (`HashPowerPerpsDEX`): an on-chain CLOB for perpetuals with maker/taker fees, funding, and permissionless liquidation. Positions form when two users' orders match. +- **futures-marketplace** (`Futures`): an on-chain CLOB for Bitcoin **hashprice** futures (forward contracts on mining revenue), with maker/taker fees and permissionless liquidation. Each matched unit is a "lot". + +Both venues share a single `CollateralVault` for collateral. + +## 2. What to incentivize + +Liquidity is the bottleneck for a new CLOB, and protocol value accrues from fee-generating volume. Priorities, in order: + +1. **Core engine — matched volume**, with a maker multiplier higher than taker early on to bootstrap liquidity (the approach Blur used at launch), gated by a minimum fee paid per side. +2. **Keeper bucket** — a small reward for executing liquidations, which keeps the books solvent. + +Referral and loyalty multipliers were considered but **deferred** (see [`points-system-improvements.md`](./points-system-improvements.md)); referral in particular is irreducibly sybil-gameable on-chain and would subsidize wash trading. + +Explicitly **not** rewarded: + +- Raw `OrderCreated` count — placing and cancelling orders is nearly free and trivially farmed. +- Deposit events — deposit/withdraw loops are free to farm; collateral that merely sits idle is low value. +- Physical delivery completion (futures) — removed for simplicity in this iteration. + +### Maker vs taker + +Both venues distinguish maker and taker on-chain: + +- perps: the `OrderMatched` event carries `maker`, `taker`, and separate `makerFee` / `takerFee`. +- futures: `LotCreated` / `LotTransferred` carry `makerOrderId` and `takerOrderId`. + +This lets the points hook (Section 5) apply different weights to each side without any off-chain inference. + +## 3. Concrete points formula (single window) + +Per unit of activity (weights are WAD-scaled; `WEIGHT_SCALE = 1e18`, so `weight = 1e18` ⇒ 1 POINT per notional unit): + +- **Taker points** = `notional * w_taker / WEIGHT_SCALE` (e.g. base 1 point per $ of notional). +- **Maker points** = `notional * w_maker / WEIGHT_SCALE`, with `w_maker > w_taker` at launch (e.g. `w_maker = 1.5 * w_taker`) to bias toward liquidity provision. +- **Minimum fee threshold** (instead of fee weighting): a side earns only if its fee paid is `>= minFee`. A maker rebate (non-positive `makerFee`) earns nothing. Fees are the most wash-resistant signal because they cost real money into the insurance fund; gating on a minimum fee ties points to genuine economic cost while keeping the hot path a single multiply. (Continuous fee-weighting was considered and dropped for simplicity — see [`points-system-improvements.md`](./points-system-improvements.md).) +- **Keeper points** = a flat number of points per liquidation executed. + +All weights are parameters of the `PointsHook` contract (Section 5), not the venue contracts, so they can be retuned without touching the trading hot path. + +## 4. Time model: single program window (no epochs) + +The program is **one continuous window** from genesis to program end. There are no recurring sub-epochs and no rollover logic. + +- Points accrue continuously (`absolute accrual = activity * weight`) over the single window. +- Conversion math at program end: `userGOV = pool * userPoints / totalPoints` — a fixed treasury GOV pool split pro-rata. This pro-rata split at the end is the only "budget" boundary. +- There is a single cumulative balance per user (the `POINTS` balance); no per-epoch entities. (Per-account caps were considered and deferred — see [`points-system-improvements.md`](./points-system-improvements.md).) + +## 5. Contracts + +All incentive contracts are **non-upgradeable, plain deploys**. Mutability that the program genuinely needs (formula retuning, disabling) is achieved by replacing the `PointsHook`, not by proxy upgrades. + +### 5.1 `Points` token (`HP`) + +- Name "Hashrate Points", symbol **`HP`**, **6 decimals** (matching GOV), **non-upgradeable**. +- **Not an ERC20 you can move — a non-transferable ledger.** It exposes the *read* side of the ERC20 interface (`name` / `symbol` / `decimals` / `balanceOf` / `totalSupply`) over a plain `mapping(address => uint256)` balances store and emits standard `Transfer` events on mint/burn, so wallets and the subgraph can track balances. But: + - there are **no allowances**; `approve` is disabled and `allowance` always returns 0, + - `transfer` / `transferFrom` **always revert** (`TransfersDisabled`), + - the only state changes are `mint` (attribution) and `burn` (redemption). +- **Roles** (OpenZeppelin `AccessControl`): + - `MINTER_ROLE` — granted to `PointsHook`; the only caller that can `mint`. + - `BURNER_ROLE` — granted to `PointsRedeemer`; the only caller that can `burn(from, amount)`. + - `DEFAULT_ADMIN_ROLE` — the Safe/owner; grants the above roles and calls `finalize()`. +- **Why a pure ledger rather than a restricted-transfer ERC20**: because POINTS are never transferred between accounts, there is nothing to gate — redemption is just a burn. The redeemer holds `BURNER_ROLE` and burns the user's balance directly in `swap()`, so there is **no `approve()` and no `transferFrom`** anywhere. Blocking all transfers also fully removes the secondary-market profile the Galaxy report flags (Section 11). +- **Lifecycle**: minting is open for the duration of the program. `finalize()` (admin) permanently freezes minting (fixing `totalSupply`) and thereby gates redemption. After `finalize()` no new points can be minted (`notFinalized` modifier on `mint`). + +Transfer gate (the entry functions, not `_update`): + +```solidity +function transfer(address, uint256) external pure returns (bool) { revert TransfersDisabled(); } +function transferFrom(address, address, uint256) external pure returns (bool) { revert TransfersDisabled(); } +function approve(address, uint256) external pure returns (bool) { revert TransfersDisabled(); } + +function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) notFinalized { /* ... */ } +function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) { /* ... */ } +``` + +### 5.2 `PointsHook` + +- **Non-upgradeable, plain deploy.** Holds `MINTER_ROLE` on `POINTS`. Contains all the points math and weight parameters. Not a fund-holding contract. +- Implements `IPointsHook` with two entry points called by the venues: + - `onFill(maker, taker, notional, makerFee, takerFee)` — called by perps `_executeMatch` and futures lot creation. Skips entirely on a self-match (`maker == taker`); otherwise mints `notional * w_taker / WEIGHT_SCALE` to the taker when `takerFee >= minFee`, and `notional * w_maker / WEIGHT_SCALE` to the maker when `makerFee > 0 && makerFee >= minFee` (a maker rebate earns nothing). Emits `FillPointsMinted(account, amount, isMaker)` per side. Note: one perps `createOrder` can walk the book and match against N resting maker orders in a single transaction, producing N `onFill` calls — so minting is O(matched levels) per taker transaction. + - `onLiquidation(liquidator, fee)` — called by perps `liquidatePosition` and futures `liquidatePosition` / `liquidateOrder`. Mints flat keeper points to the liquidator. +- **Caller authorization**: the hook checks that the caller holds a `HOOK_CALLER_ROLE`, granted only to the two venue contracts, so arbitrary addresses cannot mint points by calling the hook directly. +- **Retuning**: changing `w_maker`, `w_taker`, or the keeper rate is done by deploying a new `PointsHook` and calling `setHook()` on each venue. No proxy is required because the hook is designed to be **replaced**, not upgraded. + +```solidity +interface IPointsHook { + function onFill( + address maker, + address taker, + uint256 notional, + int256 makerFee, + uint256 takerFee + ) external; + + function onLiquidation(address liquidator, uint256 fee) external; +} +``` + +### 5.3 Venue wiring (perps, futures) + +Venue changes are deliberately minimal and live in the venue repos, not here: + +- Each venue stores a `hook` address with a `setHook(address)` owner setter. +- Each venue adds call sites that invoke the hook with failure isolation: + +```solidity +if (hook != address(0)) { + try IPointsHook(hook).onFill(maker, taker, notional, makerFee, takerFee) {} + catch {} +} +``` + +- The `try/catch` is the **failure isolation**: a bug or revert in the points hook must never block a fill, a liquidation, or any trading-critical path, and must never let points logic DoS the matching engine. +- Setting `hook = address(0)` disables points entirely, with no contract upgrade. +- The only thing the venue repos import from collateral-margin is the `IPointsHook` interface. + +### 5.4 In-protocol anti-gaming + +- **Self-match exclusion** in the hook (and reinforceable at the venue): `onFill` returns early when `maker == taker`. Perps already exposes `Fill.counterparty`, and futures exposes `Lot.seller` / `Lot.buyer` plus `makerOrderId` / `takerOrderId`, so a venue can also skip the call. +- **Minimum fee threshold** (`minFee`) per side, so dust trades cannot be spammed for points and maker rebates earn nothing. +- **Positive-fees invariant** at the venue config (Section 8). + +Per-account caps were considered as a further defense but deferred (see [`points-system-improvements.md`](./points-system-improvements.md)). + +## 6. POINTS -> GOV conversion + +- GOV is a fixed 50M supply, mint-once token (no live distributor today), so the swap is funded from a **treasury-funded GOV pool**, not new minting. +- Conversion is enabled only **after `finalize()`** (minting frozen, so `totalPoints` is fixed). +- `PointsRedeemer` holds `BURNER_ROLE` on `POINTS`. A user calls `swap()`; the redeemer reads the caller's balance and **burns it directly** via `burn(user, balance)` — there is no `transferFrom` and **no `approve()`**, because POINTS cannot move. +- Payout is pro-rata against a snapshot taken when redemption is enabled: `userGOV = govPool * userPoints / totalPointsSnapshot`. `previewSwap(user)` quotes the payout off-chain. +- The GOV payout is split **50/50** between liquid GOV and `VestingEscrow.lockFor` (the existing governance-token escrow: 180-day cliff + 90-day linear vest, with the 1.5x relock bonus available), reusing the `TokenMigration` pattern already in the governance-token repo. +- Because POINTS is already an on-chain balance the redeemer can burn directly, **no Merkle distributor is needed**. +- The swap and the pool size are **discretionary** (the pool can be zero). Per the legal analysis in the Galaxy report, conversion is the step that creates the most regulatory exposure, so it is kept discretionary and not promised. + +### Future GOV migration + +If a later program replaces POINTS with real GOV, a migration contract similar to the existing `TokenMigration.migrate()` pattern is granted `BURNER_ROLE` and burns the user's POINTS within the same **user-initiated** `migrate()` transaction while distributing the new token. No prior `approve()` is needed (POINTS has no allowances), so the UX is a single transaction. Migration is user-initiated, not a protocol batch sweep. + +## 7. Indexer: leaderboard + mirror + +The `Points` balance is the **canonical** ledger. The indexer is not the source of truth; it serves two purposes: + +1. **Live leaderboard** — the primary user-facing surface. A `UserPoints` entity, queryable by any frontend with `orderBy: total`. +2. **Mirror** — keeps the leaderboard in sync with on-chain events, so it always reflects canonical balances, with a per-category breakdown (maker / taker / keeper) derived from the hook's own events. + +Design — the subgraph indexes **the points contracts only**, not the venue contracts: + +- **Three data sources**, all in collateral-margin and deployable on a single chain: + - `Points` — `Transfer` (mint/burn → `total`, `totalSupply`) and `Finalized` (program lifecycle). + - `PointsHook` — `FillPointsMinted(account, amount, isMaker)` and `KeeperPointsMinted(liquidator, amount)`. These carry the **category breakdown** (maker / taker / keeper) directly, so the subgraph never re-derives the points formula. + - `PointsRedeemer` — `RedemptionEnabled` and `Swapped` (burn + GOV payout split), feeding `PointsRedemption` entities. +- **Why hook events, not venue events**: the hook is the single source of truth for the formula, and it emits exactly the amount it minted. Indexing the hook avoids re-implementing maker/taker math in AssemblyScript and avoids drifting from the contract when weights change. It also removes the cross-network problem — there is one hook regardless of how many venues call it. +- **Dedicated subgraph**, not an extension of the production accounting subgraph. The points formula is volatile (it changes when the hook is redeployed); keeping it separate lets it re-sync independently of the accounting subgraph that keepers and the market maker depend on. +- **Mirror exactness**: `total` is reconciled from `Points.Transfer`, while the maker/taker/keeper split comes from the hook events — the two are asserted to agree in the subgraph tests. +- Entities: `PointsProgram` (totals + finalized flag), `UserPoints` (`total`, `makerPoints`, `takerPoints`, `keeperPoints`), `PointsMint`, `PointsCategory`, `PointsRedemption`. + +## 8. Accepted tradeoffs + +This design chose **on-chain hook-based minting** over off-chain accounting. The off-chain alternative (a service that reads the subgraphs and stores points in a database) was considered and rejected. The reasoning is recorded here so it is not relitigated: + +- **Gas**: negligible on Base (an SSTORE + a mint event per fill). Not a real cost. +- **Formula transparency**: a non-issue. Anyone can reverse-engineer the formula from a single trade, so there is nothing to hide; the formula is intentionally public. +- **Formula mutability**: solved by the hook approach. Retuning weights is a new `PointsHook` deploy + `setHook()`, not a UUPS upgrade of the fund-holding perps/futures contracts and not a re-audit of the trading hot path. +- **Wash trading**: trade fees make wash trading costly, and the unknown pre-TGE GOV price removes the certain-arbitrage motive that drove the LooksRare wash explosion (where LOOKS was already liquid and priced, making wash a calculable risk-free arbitrage). On this basis, **sybil / cluster detection is out of scope** for this iteration. The defenses retained — the positive-fees invariant, self-match exclusion, and a minimum fee threshold — are sufficient for a bootstrap program. Referral, loyalty, and per-account caps were deliberately deferred rather than shipped half-built (see [`points-system-improvements.md`](./points-system-improvements.md)); referral in particular is irreducibly sybil-gameable and was the clearest cut. + +### Positive-fees invariant (hard dependency) + +The net trade fee (`makerFee + takerFee`) must stay **strictly positive** while points are live — in particular, no negative `makerFeeBps` (maker rebate) on perps. A maker rebate would turn wash trading into a net-profit subsidy (rebate + points > cost), breaking the primary economic deterrent. This must be enforced as a deploy-time / configuration invariant for as long as the program runs. + +## 9. Repo placement + +Everything incentives-related lives in **collateral-margin**, the shared infrastructure repo used by both venues, making it the natural home for cross-venue components: + +- **Design document**: `collateral-margin/docs/points-system-design.md` (this file); deferred features in `collateral-margin/docs/points-system-improvements.md`. +- **Contracts** (`Points`, `PointsHook`, `PointsRedeemer`, plus `GovTokenMock` / `VestingEscrowMock` for tests): `collateral-margin/contracts/contracts/`, with tests in `collateral-margin/contracts/tests/` and a `deploy-points.ts` script (`pnpm deploy:points`). +- **Points subgraph** (leaderboard + mirror): `collateral-margin/points-indexer/`, separate from the existing accounting subgraph. +- **Venue wiring** (the `hook` address + `setHook` setter + the two `try/catch` call sites + `HOOK_CALLER_ROLE` grant): in the venue repos `perps/` and `futures-marketplace/`. They import only the `IPointsHook` interface from collateral-margin. +- **GOV / `VestingEscrow`**: unchanged, in the `governance-token` repo. `PointsRedeemer` calls into the existing `VestingEscrow.lockFor`. + +### Upstream coupling mitigations + +Because the points subgraph indexes the **points contracts only** (`Points`, `PointsHook`, `PointsRedeemer`) and not the UUPS-upgradeable venue contracts, it does not depend on venue event signatures — a venue upgrade cannot silently break the leaderboard. The only coupling is the `IPointsHook` interface the venues import; that surface is small and pinned. Remaining hygiene: + +- Vendor pinned ABI files for the three points contracts into `points-indexer/abis/` (reusing the org's existing `contracts/abi` -> `keeper/src/abi.ts` copy convention). +- Track an explicit start block per data source. + +## 10. Data + value flow + +```mermaid +flowchart TD + trade["Trade / liquidation on perps / futures"] -->|"try onFill() / onLiquidation()"| pointsHook["PointsHook contract"] + pointsHook -->|"Points.mint()"| points["Points ledger (non-transferable, HP)"] + pointsHook --> mirror["Points subgraph -> leaderboard"] + points --> mirror + points --> finalize["finalize() freezes minting"] + finalize --> swap["PointsRedeemer: burn POINTS -> GOV"] + swap --> liquid["50% liquid GOV"] + swap --> vest["50% VestingEscrow.lockFor"] +``` + +## 11. Legal / ops notes + +- Clear terms & conditions; likely geo-fence US IP addresses at the frontend (as Marginfi did); no guaranteed-conversion language (the pool is discretionary). +- **All transfers are blocked** (`transfer` / `transferFrom` / `approve` revert; no allowances), so POINTS cannot be sold on secondary points markets (Whales Market / Pendle) — this removes a wash-trade exit and lowers the "tradeable quasi-asset" profile the Galaxy report flags. The only balance changes are protocol-side mint (attribution) and burn (redemption), so there is no secondary market by construction. +- Conversion is the highest-risk step (Howey / SEC exposure per the report), so it is kept discretionary and unpromised until the program decides to enable it. + +## 12. Open items / preconditions + +- **Same-chain deployment** of perps and futures with the single `PointsHook` is required for cross-venue minting (both venues call the same hook). The points subgraph itself only needs the points contracts, which deploy together. +- Final weight values (`w_maker`, `w_taker`, keeper rate) and the minimum-fee threshold (`minFee`) are parameters to be set on `PointsHook` at deploy / via the admin setters. +- The treasury GOV pool size and the decision to enable conversion at all remain discretionary. +- Deferred features (referral, loyalty, per-account caps, sybil/cluster detection) are tracked in `points-system-improvements.md` for a future iteration. diff --git a/docs/points-system-improvements.md b/docs/points-system-improvements.md new file mode 100644 index 0000000..64deb87 --- /dev/null +++ b/docs/points-system-improvements.md @@ -0,0 +1,142 @@ +# Points System — Deferred Features & Future Improvements + +## Status + +Backlog. These features were intentionally **removed from the shipped `PointsHook`** to +keep the first iteration minimal, auditable, and hard to game. Each entry records the +design (often the exact implementation that was cut) and the rationale, so it can be +re-introduced deliberately rather than reinvented. + +The shipped hook keeps only: maker/taker notional weights, a flat keeper reward, a +per-side minimum-fee threshold, and self-match exclusion. Everything below is additive. + +--- + +## 1. Loyalty / streak multiplier + +**Idea.** Reward sustained activity by multiplying a fill's base points by a bonus that +grows with the number of *consecutive active days* an account has traded. + +**Cut design (drop-in).** Two tunable parameters and two per-account storage slots: + +- `loyaltyStepBps` — bonus added per consecutive active day (bps of base points). +- `loyaltyMaxBps` — cap on the cumulative bonus. +- `mapping(address => uint64) lastActivityDay` — day index of last earning activity. +- `mapping(address => uint32) streakDays` — current consecutive-day streak. + +Applied inside accrual before minting: + +```solidity +function _applyLoyalty(address account, uint256 base) internal returns (uint256) { + if (loyaltyStepBps == 0 || base == 0) return base; + + uint64 today = uint64(block.timestamp / 1 days); + uint64 last = lastActivityDay[account]; + uint32 streak = streakDays[account]; + + if (last == 0 || today > last + 1) { + streak = 1; // first activity, or a missed day → reset + } else if (today == last + 1) { + streak += 1; // consecutive day → extend streak + } + // today == last: same-day activity keeps the streak unchanged. + + lastActivityDay[account] = today; + streakDays[account] = streak; + + uint256 bonusBps = uint256(streak - 1) * loyaltyStepBps; // day 1 has no bonus + if (bonusBps > loyaltyMaxBps) bonusBps = loyaltyMaxBps; + return base + (base * bonusBps) / BPS; // BPS = 10_000 +} +``` + +**Why deferred.** + +- Adds two SSTOREs to the trading hot path (`onFill` runs O(matched levels) per taker tx). +- A `block.timestamp / 1 days` boundary is sybil-amplifiable: a farmer can spread activity + across sybils to build many streaks, and the deterministic day boundary is easy to + optimise against. The benefit (retention) is real but not worth the added surface in v1. + +**Improvement ideas before re-adding.** + +- Weight the streak bonus by *volume* on the active day, not mere presence, so a dust + trade can't keep a streak alive. +- Use a rolling, decaying activity score rather than a hard day boundary. +- Consider computing loyalty off-chain from the subgraph and applying it only at + conversion time, keeping the hot path clean. + +--- + +## 2. Referral rewards + +**Idea.** A referrer earns `referralBps` of their referees' freshly-earned points. + +**Cut design.** A self-registered `mapping(address => address) referrerOf` (set once, +never self-referential), plus a `_payReferral` step that mints `referralBps` of each +referee's award to their referrer. + +**Why deferred — sybil-gameable by construction.** + +Referral cannot be made sybil-resistant on-chain without identity / cluster detection, +which `points-system-design.md` §8 explicitly puts out of scope. Worse, it *undermines +the program's core economic deterrent*: + +- An attacker points N sybils' referrals at one wallet and earns `referralBps` of **free** + points on volume they were doing anyway — pure extra yield on top of fees, which lowers + the effective cost of wash trading (the exact thing the positive-fees invariant is meant + to make unprofitable). +- Splitting one trader's volume across sybils-all-referring-home also inflates that + cluster's share of the pro-rata GOV pool versus an honest single-account user. + +**Improvement ideas before re-adding.** + +- Gate referral payouts on off-chain sybil/cluster scoring (out of scope for v1). +- Fund referral from a separate, capped budget instead of fresh mints, and cap per-referrer + totals — limits magnitude but does not fix the underlying sybil incentive. +- Require referees to pass a meaningful activity/seniority threshold before referral accrues. + +--- + +## 3. Per-account caps + +**Idea.** A single-window cumulative cap on points any one account can earn, to flatten +whale dominance of the pro-rata pool. + +**Cut design.** A `uint256 accountCap` (0 == uncapped) plus `mapping(address => uint256) +earned`, with awards clamped to the remaining room: + +```solidity +function _mintCapped(address account, uint256 amount) internal returns (uint256) { + if (amount == 0) return 0; + if (accountCap != 0) { + uint256 already = earned[account]; + if (already >= accountCap) return 0; + uint256 room = accountCap - already; + if (amount > room) amount = room; + } + earned[account] += amount; + points.mint(account, amount); + return amount; +} +``` + +**Why deferred.** + +- A flat per-account cap is trivially defeated by splitting across wallets — without sybil + detection it mostly penalises honest large traders rather than farmers. +- Adds an SSTORE (`earned`) to the hot path. + +**Improvement ideas before re-adding.** + +- Pair with sybil/cluster detection so the cap applies per *entity*, not per address. +- Prefer a soft diminishing-returns curve (e.g. sqrt of volume) over a hard cap. + +--- + +## 4. Sybil / cluster detection (cross-cutting prerequisite) + +Most of the above only become safe and meaningful once accounts can be clustered into +entities. This is explicitly out of scope for the bootstrap program (design §8), but it is +the single highest-leverage improvement: it would unlock referral, per-entity caps, and a +volume-weighted loyalty curve simultaneously. Most viable as an **off-chain scoring service +reading the points subgraph**, applied at conversion time rather than at mint time. diff --git a/points-indexer/.env.example b/points-indexer/.env.example new file mode 100644 index 0000000..0b691ee --- /dev/null +++ b/points-indexer/.env.example @@ -0,0 +1,16 @@ +# ── Subgraph manifest ────────────────────────────────────────────────────── +# Hard precondition (design §7): Points, PointsHook and PointsRedeemer must all +# be deployed on the SAME network. A single subgraph cannot index across networks. +NETWORK=base-sepolia + +POINTS_ADDRESS=0x0000000000000000000000000000000000000000 +POINTS_START_BLOCK=0 + +HOOK_ADDRESS=0x0000000000000000000000000000000000000000 +HOOK_START_BLOCK=0 + +REDEEMER_ADDRESS=0x0000000000000000000000000000000000000000 +REDEEMER_START_BLOCK=0 + +# ── docker-compose (graph-node) ──────────────────────────────────────────── +ETH_NODE_ADDRESS=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY diff --git a/points-indexer/.gitignore b/points-indexer/.gitignore new file mode 100644 index 0000000..a7c8ec5 --- /dev/null +++ b/points-indexer/.gitignore @@ -0,0 +1,11 @@ +build +data +generated +subgraph.yaml +.env* +!.env.example +node_modules + +# Matchstick test runtime artifacts +tests/.bin/ +tests/.latest.json diff --git a/points-indexer/README.md b/points-indexer/README.md new file mode 100644 index 0000000..5d7f172 --- /dev/null +++ b/points-indexer/README.md @@ -0,0 +1,66 @@ +# Points Indexer + +Dedicated subgraph for the points program (design §7): a **live leaderboard** plus an +exact **mirror** of the on-chain HP balances, kept separate from the production accounting +subgraph in `../indexer` so it can re-sync independently when the `PointsHook` formula +changes. + +## What it indexes + +Three data sources, all from the points contracts in `../contracts`: + +| Source | Events | Purpose | +| --- | --- | --- | +| `Points` (HP token) | `Transfer`, `Finalized` | Canonical balance mirror: `UserPoints.total`, program `totalPoints`/`totalMinted`/`totalBurned`, `finalized`. | +| `PointsHook` | `FillPointsMinted`, `KeeperPointsMinted` | Per-category breakdown (maker / taker / keeper) + individual `PointsMint` rows. | +| `PointsRedeemer` | `Swapped` | Redemption tracking: `redeemedPoints`, `govReceived`, `PointsRedemption` rows. | + +### Why hook events instead of venue events + +The design sketch listed `HashPowerPerpsDEX` + `Futures` as data sources and proposed +copying their volume/fee/maker-taker math into the subgraph. We index the **hook's own +events** instead, because `PointsHook` already emits the exact minted amount and category +(`FillPointsMinted(account, amount, isMaker)`, `KeeperPointsMinted(liquidator, amount)`). +That removes the need to re-derive the points formula off-chain entirely, so there is **no +logic-drift risk** when weights change — the subgraph never has to be kept in sync with the +weight parameters. The canonical balance still comes straight from the HP `Transfer` stream. + +## Leaderboard query + +```graphql +{ + userPoints(first: 100, orderBy: total, orderDirection: desc) { + address + total + makerPoints + takerPoints + keeperPoints + redeemedPoints + } + pointsProgram(id: "0") { + totalPoints + totalUsers + finalized + } +} +``` + +## Preconditions + +- **Same-chain deployment (hard requirement).** `Points`, `PointsHook`, and + `PointsRedeemer` must be deployed on the same network with finalized addresses; a single + subgraph cannot index across networks. Set them in `.env`. +- ABIs are read from `../contracts/abi/*.json`, generated by the contracts package + (`pnpm --filter collateral-margin-contracts compile`). Pin them to the deployed + implementation version. + +## Develop + +```bash +cp .env.example ../.env # or set NETWORK / *_ADDRESS / *_START_BLOCK in ../.env +pnpm install +pnpm prepare-local # envsubst → subgraph.yaml +pnpm codegen +pnpm build +pnpm test # matchstick +``` diff --git a/points-indexer/package.json b/points-indexer/package.json new file mode 100644 index 0000000..b01adf8 --- /dev/null +++ b/points-indexer/package.json @@ -0,0 +1,34 @@ +{ + "name": "points-indexer", + "license": "UNLICENSED", + "engines": { + "node": ">=22.6.0" + }, + "type": "module", + "scripts": { + "clean": "rm -rf data generated build subgraph.yaml", + "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", + "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", + "codegen": "graph codegen", + "build": "graph build", + "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ points", + "create-local": "graph create --node http://localhost:8020/ points", + "remove-local": "graph remove --node http://localhost:8020/ points", + "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 points", + "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", + "test": "graph test", + "indexer": "docker compose --env-file ../.env up", + "graph:api": "open http://localhost:8030/graphql/playground" + }, + "dependencies": { + "@graphprotocol/graph-ts": "0.38.2" + }, + "devDependencies": { + "@graphprotocol/graph-cli": "^0.98.1", + "@types/node": "^25.3.0", + "assemblyscript": "^0.19.23", + "matchstick-as": "0.6.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" +} diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml new file mode 100644 index 0000000..a87fe68 --- /dev/null +++ b/points-indexer/pnpm-lock.yaml @@ -0,0 +1,3610 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@graphprotocol/graph-ts': + specifier: 0.38.2 + version: 0.38.2 + devDependencies: + '@graphprotocol/graph-cli': + specifier: ^0.98.1 + version: 0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76) + '@types/node': + specifier: ^25.3.0 + version: 25.9.2 + assemblyscript: + specifier: ^0.19.23 + version: 0.19.23 + matchstick-as: + specifier: 0.6.0 + version: 0.6.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@chainsafe/is-ip@2.1.0': + resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} + + '@chainsafe/netmask@2.0.0': + resolution: {integrity: sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg==} + + '@dnsquery/dns-packet@6.1.1': + resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} + engines: {node: '>=6'} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5': + resolution: {integrity: sha512-yZ0H5e3EpAYKokX/AbtplzlvSxEJY7ZfpvQyDzyODkks0hakAAlDG6fQu1SlDJMWorY7bbq1j7fCiFeTWci6TA==} + hasBin: true + + '@graphprotocol/graph-cli@0.98.1': + resolution: {integrity: sha512-GrWFcRCBlLcRT+gIGundQl7yyrX3YWUPj66bxThKf5CJvvWXdZoNxrj27dMMqulsSwYmpCkb3YmpCiVJFGdpHw==} + engines: {node: '>=20.18.1'} + hasBin: true + + '@graphprotocol/graph-ts@0.38.2': + resolution: {integrity: sha512-87KIFSFs2+Te+mnmb7Y+M57oqzlLy20cIyPIRbn9qJfpZFSZHTKtBLT6KQmcsK0YkoWis9Ur3c3M2c9mmaaEHQ==} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ipld/dag-cbor@9.2.7': + resolution: {integrity: sha512-ZmfXmElRWATr+hoUTSAOr6HUcjVhOcNHDqgczc76qte2DHHFEK0ZhNzUcdTDQhF/VSIvf2ioaRTRLWwLc83sNw==} + + '@ipld/dag-json@10.2.9': + resolution: {integrity: sha512-opNPQQsTuCFZkaJCAqXrB/n9OqUD6W2Boz/Au5HjhLQyczmT8lxoOZObqQ5S5hhnV8p6sgKAimNhUB2W6y0Mzg==} + + '@ipld/dag-pb@4.1.7': + resolution: {integrity: sha512-/i/13trFihjWfDyXlylRwhuYjtzYjvOFw0vlRjYGnZuv7d7MOgA2lV/vRuL5RfeUajM03aZfFLdq4S7cTbbTRg==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@libp2p/crypto@5.1.19': + resolution: {integrity: sha512-hYNeHQpUSwLPopWCgDf6OklOvVwJPS/vYZOiBG3VXPIzx5AkONBOfdkgVwB4YsIBH2V6z+TMgMH0yXUZsMurPA==} + + '@libp2p/interface@2.11.0': + resolution: {integrity: sha512-0MUFKoXWHTQW3oWIgSHApmYMUKWO/Y02+7Hpyp+n3z+geD4Xo2Rku2gYWmxcq+Pyjkz6Q9YjDWz3Yb2SoV2E8Q==} + + '@libp2p/interface@3.2.3': + resolution: {integrity: sha512-OKZFrY+x8IYl4Fr/YWjh4s6+uks5zQBIAdg2cl+zaRUpPORfk1ELI4r+eB+fUlXR9mHDsQylSSzmAqi0drDfiA==} + + '@libp2p/logger@5.2.0': + resolution: {integrity: sha512-OEFS529CnIKfbWEHmuCNESw9q0D0hL8cQ8klQfjIVPur15RcgAEgc1buQ7Y6l0B6tCYg120bp55+e9tGvn8c0g==} + + '@libp2p/peer-id@5.1.9': + resolution: {integrity: sha512-cVDp7lX187Epmi/zr0Qq2RsEMmueswP9eIxYSFoMcHL/qcvRFhsxOfUGB8361E26s2WJvC9sXZ0oJS9XVueJhQ==} + + '@multiformats/dns@1.0.13': + resolution: {integrity: sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==} + + '@multiformats/multiaddr-to-uri@11.0.2': + resolution: {integrity: sha512-SiLFD54zeOJ0qMgo9xv1Tl9O5YktDKAVDP4q4hL16mSq4O4sfFNagNADz8eAofxd6TfQUzGQ3TkRRG9IY2uHRg==} + + '@multiformats/multiaddr@12.5.1': + resolution: {integrity: sha512-+DDlr9LIRUS8KncI1TX/FfUn8F2dl6BIxJgshS/yFQCNB5IAF0OGzcwB39g5NLE22s4qqDePv0Qof6HdpJ/4aQ==} + + '@multiformats/multiaddr@13.0.3': + resolution: {integrity: sha512-mEqqJ4r3a/uuFMTpRkU316wGNIDQNhuVWpm+ebKTQeYsfv9jXbPONWM6VVnj3KGUrwfsX7GZOyp4TFqEA2SPCw==} + + '@noble/curves@1.4.2': + resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oclif/core@4.11.4': + resolution: {integrity: sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==} + engines: {node: '>=18.0.0'} + + '@oclif/core@4.5.5': + resolution: {integrity: sha512-iQzlaJQgPeUXrtrX71OzDwxPikQ7c2FhNd8U8rBB7BCtj2XYfmzBT/Hmbc+g9OKDIG/JkbJT0fXaWMMBrhi+1A==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-autocomplete@3.2.50': + resolution: {integrity: sha512-SQRIJSYue/1tIn7X55W/97gTb8UkSoHeFAcBng2r2YMJyWj8uB1DtFl28D8BDXPQXPTiPK89hQGejoT7RdkR2w==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-not-found@3.2.87': + resolution: {integrity: sha512-lKyZ4INrx5vB14HNWIkM6Vla/4rWVhOA2U7uCAj6gEBg36/KVmwYXxpZ9ckzZS0+jtLE84TVqS8NCYEhQiQojw==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-warn-if-update-available@3.1.65': + resolution: {integrity: sha512-HcSJc8SeCVUBHwc063xDL0LcpdjcamAISlisSX14VDDYQayMantvtVNOo9PmciwYpXRXfAykeH1z066YkA9JvQ==} + engines: {node: '>=18.0.0'} + + '@pinax/graph-networks-registry@0.7.1': + resolution: {integrity: sha512-Gn2kXRiEd5COAaMY/aDCRO0V+zfb1uQKCu5HFPoWka+EsZW27AlTINA7JctYYYEMuCbjMia5FBOzskjgEvj6LA==} + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.2': + resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} + engines: {node: '>=12'} + + '@rescript/std@9.0.0': + resolution: {integrity: sha512-zGzFsgtZ44mgL4Xef2gOy1hrRVdrs9mcxCOOKZrIPsmbZW14yTkaF591GXxpQvjXiHtgZ/iA9qLyWH6oSReIxQ==} + + '@scure/base@1.1.9': + resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + + '@scure/bip32@1.4.0': + resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + + '@scure/bip39@1.3.0': + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.6': + resolution: {integrity: sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + abitype@0.7.1: + resolution: {integrity: sha512-VBkRHTDZf9Myaek/dO3yMmOzB/y2s3Zo6nVU7yaw1G+TvCHAjwaJzNGN9yo4K5D8bU/VZXKP1EJpRhFr862PlQ==} + peerDependencies: + typescript: '>=4.9.4' + zod: ^3 >=3.19.1 + peerDependenciesMeta: + zod: + optional: true + + abort-error@1.0.2: + resolution: {integrity: sha512-lVgvB2NyPLqbXXhVmXcYFTC1x5K7CiVdPgdY7LGgFQWC8506oN01sPN3i9cl9ynuwF4iJ0TS9exnR7cZ9FuX4w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + + any-signal@4.2.0: + resolution: {integrity: sha512-LndMvYuAPf4rC195lk7oSFuHOYFpOszIYrNYv0gHAvz+aEhE9qPZLhmrIz5pXP2BSsPOXvsuHDXEGaiQhIh9wA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + apisauce@2.1.6: + resolution: {integrity: sha512-MdxR391op/FucS2YQRfB/NMRyCnHEPDd4h17LRIuVYi0BpGmMhpxc0shbOpfs5ahABuBEffNCGal5EcsydbBWg==} + + app-module-path@2.2.0: + resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assemblyscript@0.19.23: + resolution: {integrity: sha512-fwOQNZVTMga5KRsfY80g7cpOl4PsFQczMwHzdtgoqLXaYhkhavufKb0sB0l3T1DUxpAufA0KNhlbpuuhZUwxMA==} + hasBin: true + + assemblyscript@0.27.31: + resolution: {integrity: sha512-Ra8kiGhgJQGZcBxjtMcyVRxOEJZX64kd+XGpjWzjcjgxWJVv+CAQO0aDBk4GQVhjYbOkATarC83mHjAVGtwPBQ==} + engines: {node: '>=16', npm: '>=7'} + hasBin: true + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@0.21.4: + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binaryen@102.0.0-nightly.20211028: + resolution: {integrity: sha512-GCJBVB5exbxzzvyt8MGDv/MeUjs6gkXDvf4xOIItRBptYl0Tz5sm1o/uG95YK0L0VeG5ajDu3hRtkBP2kzqC5w==} + hasBin: true + + binaryen@116.0.0-nightly.20240114: + resolution: {integrity: sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==} + hasBin: true + + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + + blob-to-it@2.0.12: + resolution: {integrity: sha512-0zEZt8t8/QrdH4boktG19F/9fqfPWFjuh1QlK0qTCO13oUWaBAR8kpNloQNb3OWUtaA0mu8qfPy0R3CZDC8M2g==} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-readablestream-to-it@2.0.12: + resolution: {integrity: sha512-VDAcuM39JVtxZ7auqE2p0zHYk1fq+pac0cWLOQJ48MIChTZ1RjCR2PYCdL3kIisst7oGZCxYrJhfHlbNYIa0Tg==} + + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + cborg@5.1.1: + resolution: {integrity: sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==} + hasBin: true + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.0: + resolution: {integrity: sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@7.0.1: + resolution: {integrity: sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==} + engines: {node: '>=10'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dag-jose@5.1.1: + resolution: {integrity: sha512-9alfZ8Wh1XOOMel8bMpDqWsDT72ojFQCJPtwZSev9qh4f8GoCV9qrJW8jcOUhcstO8Kfm09FHGo//jqiZq3z9w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-tar@4.1.1: + resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} + engines: {node: '>=4'} + + decompress-tarbz2@4.1.1: + resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} + engines: {node: '>=4'} + + decompress-targz@4.1.1: + resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} + engines: {node: '>=4'} + + decompress-unzip@4.0.1: + resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} + engines: {node: '>=4'} + + decompress@4.2.1: + resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} + engines: {node: '>=4'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + + docker-compose@1.3.0: + resolution: {integrity: sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==} + engines: {node: '>= 6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + ejs@3.1.8: + resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-fetch@1.9.1: + resolution: {integrity: sha512-M9qw6oUILGVrcENMSRRefE1MbHPIz0h79EKIeJWK9v563aT9Qkh8aEHPO1H5vi970wPirNY+jO9OpFoLiMsMGA==} + engines: {node: '>=6'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + ethereum-cryptography@2.2.1: + resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-levenshtein@3.0.0: + resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + file-type@5.2.0: + resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} + engines: {node: '>=4'} + + file-type@6.2.0: + resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} + engines: {node: '>=4'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + + fs-jetpack@4.3.1: + resolution: {integrity: sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-iterator@1.0.2: + resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gluegun@5.2.0: + resolution: {integrity: sha512-jSUM5xUy2ztYFQANne17OUm/oAd7qSX7EBksS9bQDt9UvLPqcEkeWUebmaposb8Tx7eTTD8uJVWGRe6PYSsYkg==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql-import-node@0.0.5: + resolution: {integrity: sha512-OXbou9fqh9/Lm7vwXT0XoRN9J5+WCYKnbiTalgFDvkQERITRmcfncZs6aVABedd5B85yQU5EULS4a5pnbpuI0Q==} + peerDependencies: + graphql: '*' + + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashlru@2.3.0: + resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-call@5.3.0: + resolution: {integrity: sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==} + engines: {node: '>=8.0.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + interface-datastore@8.3.2: + resolution: {integrity: sha512-R3NLts7pRbJKc3qFdQf+u40hK8XWc0w4Qkx3OFEstC80VoaDUABY/dXA2EJPhtNC+bsrf41Ehvqb6+pnIclyRA==} + + interface-store@6.0.3: + resolution: {integrity: sha512-+WvfEZnFUhRwFxgz+QCQi7UC6o9AM0EHM9bpIe2Nhqb100NHCsTvNAn4eJgvgV2/tmLo1MP9nGxQKEcZTAueLA==} + + ipfs-unixfs@11.2.5: + resolution: {integrity: sha512-uasYJ0GLPbViaTFsOLnL9YPjX5VmhnqtWRriogAHOe4ApmIi9VAOFBzgDHsUW2ub4pEa/EysbtWk126g2vkU/g==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-natural-number@4.0.1: + resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-retry-allowed@1.2.0: + resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} + engines: {node: '>=0.10.0'} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iso-url@1.2.1: + resolution: {integrity: sha512-9JPDgCN4B7QPkLtYAAOrEuAWvP9rWvR5offAr0/SeF046wIkglqH3VXgYYP6NcsKslH80UIVgmPqNe3j7tG2ng==} + engines: {node: '>=12'} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + it-all@3.0.11: + resolution: {integrity: sha512-Gvqj6MO4GMLnFdtE68HZRpGBskNC+9+GQ+JevTGNYLyhjUuPhjDLU3jN1LpBemXJDW1bRSkczqA/qGyKlPKrcQ==} + + it-first@3.0.11: + resolution: {integrity: sha512-0ig8DKpg09V1o7JBagm3oPx3VY7WYfU5w3lpbLbqzijnfMPSvMGoMZuLm17h/RgOJXKP+9mt7vsCNiU2TW8TkQ==} + + it-glob@3.0.6: + resolution: {integrity: sha512-dFNeW4izM08QuB4uuIr+sVKUSo8ftVD/E1RnYidiUZx/i9h9mmwDSBl3kPv/TCah6HI0y1sgfHVCbrwA9FjoaQ==} + + it-last@3.0.11: + resolution: {integrity: sha512-Fg571l81nPzhZsiYjkw4dkhRqAK4oqIamTPEfAOnXI/5pYXz+dIfMVYmh9ncZs58oFNMkdF3bYFuCBTw/xJK0w==} + + it-map@3.1.6: + resolution: {integrity: sha512-wCix0FXImtIPIxhCnbz35RqWs00e/CReSZX9nZq1j46JcAzBBp57ob9/2l1WnDYEaUURIR8xCyg2NsWbOwBJFQ==} + + it-peekable@3.0.10: + resolution: {integrity: sha512-2E6+p1pelZOhzp69aaiiBuEybWzAl10uYbIdCR3Pxy8bFNnS/kgpbLtGbNbIZ6RVdU7yHHkmATYwjy52GfFEKA==} + + it-pushable@3.2.4: + resolution: {integrity: sha512-WSD7Ss4oCRfDZJT4ldLWr0Bom/muY90xxoJ5PQnU3uSKf0kxCOeehqZtiJX1ARqn+ymXGh1bxpDW9bDNHp2ivQ==} + + it-stream-types@2.0.4: + resolution: {integrity: sha512-tsX+klvMQ53J4Jm2B52vCIs7WD609ck+VS9X2TKMEv7VPY9VwaYKmSWyHek5QS0wHBtP0bWj9KMqCtAHgVKiXw==} + + it-to-stream@1.0.0: + resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jayson@4.2.0: + resolution: {integrity: sha512-VfJ9t1YLwacIubLhONk0KFeosUBwstRWQ0IRT1KDjEjnVnSOVHC3uwugyV7L0c7R9lpVyrUGT2XWiBA1UTtpyg==} + engines: {node: '>=8'} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kubo-rpc-client@5.4.1: + resolution: {integrity: sha512-v86bQWtyA//pXTrt9y4iEwjW6pt1gA18Z1famWXIR/HN5TFdYwQ3yHOlRE6JSWBDQ0rR6FOMyrrGy8To78mXow==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.lowercase@4.3.0: + resolution: {integrity: sha512-UcvP1IZYyDKyEL64mmrwoA1AbFu5ahojhTtkOUr1K9dbuxzS9ev8i4TxMMGCqRC9TE8uDaSoufNAXxRPNTseVA==} + + lodash.lowerfirst@4.3.1: + resolution: {integrity: sha512-UUKX7VhP1/JL54NXg2aq/E1Sfnjjes8fNYTNkPU8ZmsaVeBvPHKdbNaN79Re5XRL01u6wbq3j0cbYZj71Fcu5w==} + + lodash.pad@4.5.1: + resolution: {integrity: sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==} + + lodash.padend@4.6.1: + resolution: {integrity: sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==} + + lodash.padstart@4.6.1: + resolution: {integrity: sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==} + + lodash.repeat@4.1.0: + resolution: {integrity: sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.trim@4.18.0: + resolution: {integrity: sha512-q8B9MlXzN9NaTtS2JCd7kKl3RqwrVURgKEXoHDII8A/v7y3tWOq3rLEe+vN6LNvT+EYBVKVt6roNQxMkosS2aA==} + + lodash.trimend@4.18.0: + resolution: {integrity: sha512-8w2M3nZAWLN1OX/6mTPCwRlZiD/LhVyPV9l7DEbkd9wybExvg9AcCjbD19swj6oVzX5hcMZHp3/Y1b4Sl3sHKg==} + + lodash.trimstart@4.5.1: + resolution: {integrity: sha512-b/+D6La8tU76L/61/aN0jULWHkT0EeJCmVstPBn/K9MtD2qBW83AsBNrr63dKuWYwVMO7ucv13QNO/Ek/2RKaQ==} + + lodash.uppercase@4.3.0: + resolution: {integrity: sha512-+Nbnxkj7s8K5U8z6KnEYPGUOGp3woZbB7Ecs7v3LkkjLQSm2kP9SKIILitN1ktn2mB/tmM9oSlku06I+/lH7QA==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@3.0.0: + resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==} + engines: {node: '>=8'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + main-event@1.0.4: + resolution: {integrity: sha512-sKazUjIy2Jalv5lkQ446iOcrx8Q7TkaCuk6xfnzg5uUqMusMLDMPmRDmSNE2kjSVpSTJo4j1bQZusS+Ib7Bvrg==} + + make-dir@1.3.0: + resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} + engines: {node: '>=4'} + + matchstick-as@0.6.0: + resolution: {integrity: sha512-E36fWsC1AbCkBFt05VsDDRoFvGSdcZg6oZJrtIe/YDBbuFh8SKbR5FcoqDhNWqSN+F7bN/iS2u8Md0SM+4pUpw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + ms@4.0.0-nightly.202508271359: + resolution: {integrity: sha512-WC/Eo7NzFrOV/RRrTaI0fxKVbNCzEy76j2VqNV8SxDf9D69gSE2Lh0QwYvDlhiYmheBYExAvEAxVf5NoN0cj2A==} + engines: {node: '>=20'} + + multiformats@13.1.3: + resolution: {integrity: sha512-CZPi9lFZCM/+7oRolWYsvalsyWQGFo+GpdaTmjxXXomC+nP/W1Rnxb9sUgjvmNmRZ5bOPqRAl4nuK+Ydw/4tGw==} + + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + + multiformats@14.0.0: + resolution: {integrity: sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + + native-fetch@4.0.2: + resolution: {integrity: sha512-4QcVlKFtv2EYVS5MBgsGX5+NWKtbDbIECdUXDBGDMAZXq3Jkv9zf+y8iS7Ub8fEdga3GpYeazp9gauNqXHJOCg==} + peerDependencies: + undici: '*' + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + ora@4.0.2: + resolution: {integrity: sha512-YUOZbamht5mfLxPmk4M35CD/5DuOkAacxlEUbStVXpBAt4fyhBf+vZHI/HRkI++QUp3sNoeA2Gw4C+hi4eGSig==} + engines: {node: '>=8'} + + p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + + p-fifo@1.0.0: + resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-duration@2.1.6: + resolution: {integrity: sha512-1/A2Exg3NcJGcYdgV/dn4frR7vO2hOW/ohQ4KIgbT4W3raVcpYSszPWiL6I6cKufi4jQM5NbGRXLBj8AoLM4iQ==} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress-events@1.1.0: + resolution: {integrity: sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + protons-runtime@5.6.0: + resolution: {integrity: sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==} + + protons-runtime@6.0.2: + resolution: {integrity: sha512-hiyjyANwGcgmzc+tXc1/ZcSZhKnl5MDjaVNWkISHBgadaU0sjTgKIKZMZ62d9J9zlSTyKHCs/osPkQ/3Z+7yeA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-native-fetch-api@3.0.0: + resolution: {integrity: sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + seek-bzip@1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + + semver@7.3.5: + resolution: {integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.2: + resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + stream-to-it@1.0.1: + resolution: {integrity: sha512-AqHYAYPHcmvMrcLNgncE/q0Aj/ajP6A4qGhxP6EVn7K3YTNs0bJpJyk57wc2Heb7MUL64jurvmnmui8D9kjZgA==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-dirs@2.1.0: + resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-stream@1.6.2: + resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} + engines: {node: '>= 0.8.0'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uint8-varint@2.0.5: + resolution: {integrity: sha512-jeFLbL/x30wBRnWjKE1qVBXeumG46r7XmYkpis955lTQ+blccGKFrOsSMHlxePwYB1pI7L8YPHz1t4jLxEs3nA==} + + uint8-varint@3.0.0: + resolution: {integrity: sha512-S4DdpXBaLwKcFo7f0bWzWfHjbZ/i3QhM842qn+ZvHjxqFCfUcEB9SQNcmI69S+zMlcmIcKxsk9Iyw77S2Kxv6Q==} + + uint8arraylist@2.4.9: + resolution: {integrity: sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==} + + uint8arraylist@3.0.2: + resolution: {integrity: sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==} + + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} + + uint8arrays@6.1.1: + resolution: {integrity: sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==} + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + utf8-codec@1.0.0: + resolution: {integrity: sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + wabt@1.0.24: + resolution: {integrity: sha512-8l7sIOd3i5GWfTWciPL0+ff/FK/deVK2Q6FN+MPz4vfUcD78i2M/49XJTwF6aml91uIiuXJEsLKWMB2cw/mtKg==} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + weald@1.1.3: + resolution: {integrity: sha512-vMWtNbYuPb58NeG2+0sKA0Een4VMDwzf+3oHqh68buWRSOMUBlUeRb11LhV28czV+DUpJHRykifijZDuS9bInA==} + + web3-errors@1.3.1: + resolution: {integrity: sha512-w3NMJujH+ZSW4ltIZZKtdbkbyQEvBzyp3JRn59Ckli0Nz4VMsVq8aF1bLWM7A2kuQ+yVEm3ySeNU+7mSRwx7RQ==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-eth-abi@4.4.1: + resolution: {integrity: sha512-60ecEkF6kQ9zAfbTY04Nc9q4eEYM0++BySpGi8wZ2PD1tw/c0SDvsKhV6IKURxLJhsDlb08dATc3iD6IbtWJmg==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-types@1.10.0: + resolution: {integrity: sha512-0IXoaAFtFc8Yin7cCdQfB9ZmjafrbP6BO0f0KT/khMhXKUpoJ6yShrVhiNpyRBo8QQjuOagsWzwSK2H49I7sbw==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-utils@4.3.3: + resolution: {integrity: sha512-kZUeCwaQm+RNc2Bf1V3BYbF29lQQKz28L0y+FA4G0lS8IxtJVGi5SeDTUkpwqqkdHHC7JcapPDnyyzJ1lfWlOw==} + engines: {node: '>=14', npm: '>=6.12.0'} + + web3-validator@2.0.6: + resolution: {integrity: sha512-qn9id0/l1bWmvH4XfnG/JtGKKwut2Vokl6YXP5Kfg424npysmtRLe9DgiNBM9Op7QL/aSiaA0TVXibuIuWcizg==} + engines: {node: '>=14', npm: '>=6.12.0'} + + wherearewe@2.0.1: + resolution: {integrity: sha512-XUguZbDxCA2wBn2LoFtcEhXL6AXo+hVjGonwhSTTTU9SzbWG8Xu3onNIpzf9j/mYUcJQ0f+m37SzG77G851uFw==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@chainsafe/is-ip@2.1.0': {} + + '@chainsafe/netmask@2.0.0': + dependencies: + '@chainsafe/is-ip': 2.1.0 + + '@dnsquery/dns-packet@6.1.1': + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + utf8-codec: 1.0.0 + + '@fastify/busboy@3.2.0': {} + + '@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5': + dependencies: + '@rescript/std': 9.0.0 + graphql: 16.11.0 + graphql-import-node: 0.0.5(graphql@16.11.0) + js-yaml: 4.1.0 + + '@graphprotocol/graph-cli@0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@float-capital/float-subgraph-uncrashable': 0.0.0-internal-testing.5 + '@oclif/core': 4.5.5 + '@oclif/plugin-autocomplete': 3.2.50 + '@oclif/plugin-not-found': 3.2.87(@types/node@25.9.2) + '@oclif/plugin-warn-if-update-available': 3.1.65 + '@pinax/graph-networks-registry': 0.7.1 + '@whatwg-node/fetch': 0.10.13 + assemblyscript: 0.19.23 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + decompress: 4.2.1 + docker-compose: 1.3.0 + fs-extra: 11.3.2 + glob: 11.0.3 + gluegun: 5.2.0(debug@4.4.3) + graphql: 16.11.0 + immutable: 5.1.4 + jayson: 4.2.0 + js-yaml: 4.1.0 + kubo-rpc-client: 5.4.1(undici@7.16.0) + open: 10.2.0 + prettier: 3.6.2 + progress: 2.0.3 + semver: 7.7.3 + tmp-promise: 3.0.3 + undici: 7.16.0 + web3-eth-abi: 4.4.1(typescript@5.9.3)(zod@3.25.76) + yaml: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - supports-color + - typescript + - utf-8-validate + - zod + + '@graphprotocol/graph-ts@0.38.2': + dependencies: + assemblyscript: 0.27.31 + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/confirm@5.1.21(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/core@10.3.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/editor@4.2.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/expand@4.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/external-editor@1.0.3(@types/node@25.9.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/number@3.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/password@4.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/prompts@7.10.1(@types/node@25.9.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.2) + '@inquirer/confirm': 5.1.21(@types/node@25.9.2) + '@inquirer/editor': 4.2.23(@types/node@25.9.2) + '@inquirer/expand': 4.0.23(@types/node@25.9.2) + '@inquirer/input': 4.3.1(@types/node@25.9.2) + '@inquirer/number': 3.0.23(@types/node@25.9.2) + '@inquirer/password': 4.0.23(@types/node@25.9.2) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.2) + '@inquirer/search': 3.2.2(@types/node@25.9.2) + '@inquirer/select': 4.4.2(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/rawlist@4.1.11(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/search@3.2.2(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/select@4.4.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/type@3.0.10(@types/node@25.9.2)': + optionalDependencies: + '@types/node': 25.9.2 + + '@ipld/dag-cbor@9.2.7': + dependencies: + cborg: 5.1.1 + multiformats: 13.4.2 + + '@ipld/dag-json@10.2.9': + dependencies: + cborg: 5.1.1 + multiformats: 13.4.2 + + '@ipld/dag-pb@4.1.7': + dependencies: + multiformats: 14.0.0 + + '@isaacs/cliui@9.0.0': {} + + '@leichtgewicht/ip-codec@2.0.5': {} + + '@libp2p/crypto@5.1.19': + dependencies: + '@libp2p/interface': 3.2.3 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + multiformats: 14.0.0 + protons-runtime: 6.0.2 + uint8arraylist: 2.4.9 + uint8arrays: 6.1.1 + + '@libp2p/interface@2.11.0': + dependencies: + '@multiformats/dns': 1.0.13 + '@multiformats/multiaddr': 12.5.1 + it-pushable: 3.2.4 + it-stream-types: 2.0.4 + main-event: 1.0.4 + multiformats: 13.4.2 + progress-events: 1.1.0 + uint8arraylist: 2.4.9 + + '@libp2p/interface@3.2.3': + dependencies: + '@multiformats/dns': 1.0.13 + '@multiformats/multiaddr': 13.0.3 + main-event: 1.0.4 + multiformats: 14.0.0 + progress-events: 1.1.0 + uint8arraylist: 2.4.9 + + '@libp2p/logger@5.2.0': + dependencies: + '@libp2p/interface': 2.11.0 + '@multiformats/multiaddr': 12.5.1 + interface-datastore: 8.3.2 + multiformats: 13.4.2 + weald: 1.1.3 + + '@libp2p/peer-id@5.1.9': + dependencies: + '@libp2p/crypto': 5.1.19 + '@libp2p/interface': 2.11.0 + multiformats: 13.4.2 + uint8arrays: 5.1.1 + + '@multiformats/dns@1.0.13': + dependencies: + '@dnsquery/dns-packet': 6.1.1 + '@libp2p/interface': 3.2.3 + hashlru: 2.3.0 + p-queue: 9.3.0 + progress-events: 1.1.0 + uint8arrays: 5.1.1 + + '@multiformats/multiaddr-to-uri@11.0.2': + dependencies: + '@multiformats/multiaddr': 12.5.1 + + '@multiformats/multiaddr@12.5.1': + dependencies: + '@chainsafe/is-ip': 2.1.0 + '@chainsafe/netmask': 2.0.0 + '@multiformats/dns': 1.0.13 + abort-error: 1.0.2 + multiformats: 13.4.2 + uint8-varint: 2.0.5 + uint8arrays: 5.1.1 + + '@multiformats/multiaddr@13.0.3': + dependencies: + '@chainsafe/is-ip': 2.1.0 + multiformats: 14.0.0 + uint8-varint: 3.0.0 + uint8arrays: 6.1.1 + + '@noble/curves@1.4.2': + dependencies: + '@noble/hashes': 1.4.0 + + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@1.4.0': {} + + '@noble/hashes@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oclif/core@4.11.4': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.5 + semver: 7.8.2 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.17 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/core@4.5.5': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 9.0.9 + semver: 7.7.3 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.17 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-autocomplete@3.2.50': + dependencies: + '@oclif/core': 4.5.5 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + transitivePeerDependencies: + - supports-color + + '@oclif/plugin-not-found@3.2.87(@types/node@25.9.2)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@25.9.2) + '@oclif/core': 4.11.4 + ansis: 3.17.0 + fast-levenshtein: 3.0.0 + transitivePeerDependencies: + - '@types/node' + + '@oclif/plugin-warn-if-update-available@3.1.65': + dependencies: + '@oclif/core': 4.5.5 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + http-call: 5.3.0 + lodash: 4.18.1 + registry-auth-token: 5.1.1 + transitivePeerDependencies: + - supports-color + + '@pinax/graph-networks-registry@0.7.1': {} + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.2': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@rescript/std@9.0.0': {} + + '@scure/base@1.1.9': {} + + '@scure/bip32@1.4.0': + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.2 + + '@types/node@12.20.55': {} + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/parse-json@4.0.2': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 25.9.2 + + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.6 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.6': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + abitype@0.7.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + typescript: 5.9.3 + optionalDependencies: + zod: 3.25.76 + + abort-error@1.0.2: {} + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@3.17.0: {} + + any-signal@4.2.0: {} + + apisauce@2.1.6(debug@4.4.3): + dependencies: + axios: 0.21.4(debug@4.4.3) + transitivePeerDependencies: + - debug + + app-module-path@2.2.0: {} + + argparse@2.0.1: {} + + assemblyscript@0.19.23: + dependencies: + binaryen: 102.0.0-nightly.20211028 + long: 5.3.2 + source-map-support: 0.5.21 + + assemblyscript@0.27.31: + dependencies: + binaryen: 116.0.0-nightly.20240114 + long: 5.3.2 + + async@3.2.6: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@0.21.4(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + binaryen@102.0.0-nightly.20211028: {} + + binaryen@116.0.0-nightly.20240114: {} + + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + blob-to-it@2.0.12: + dependencies: + browser-readablestream-to-it: 2.0.12 + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-readablestream-to-it@2.0.12: {} + + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + + buffer-crc32@0.2.13: {} + + buffer-fill@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + cborg@5.1.1: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chardet@2.1.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.0: + dependencies: + object-assign: 4.1.1 + string-width: 4.2.3 + optionalDependencies: + colors: 1.4.0 + + cli-width@4.1.0: {} + + clone@1.0.4: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colors@1.4.0: {} + + commander@2.20.3: {} + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + content-type@1.0.5: {} + + core-util-is@1.0.3: {} + + cosmiconfig@7.0.1: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dag-jose@5.1.1: + dependencies: + '@ipld/dag-cbor': 9.2.7 + multiformats: 13.1.3 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decompress-tar@4.1.1: + dependencies: + file-type: 5.2.0 + is-stream: 1.1.0 + tar-stream: 1.6.2 + + decompress-tarbz2@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 6.2.0 + is-stream: 1.1.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + + decompress-targz@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 5.2.0 + is-stream: 1.1.0 + + decompress-unzip@4.0.1: + dependencies: + file-type: 3.9.0 + get-stream: 2.3.1 + pify: 2.3.0 + yauzl: 2.10.0 + + decompress@4.2.1: + dependencies: + decompress-tar: 4.1.1 + decompress-tarbz2: 4.1.1 + decompress-targz: 4.1.1 + decompress-unzip: 4.0.1 + graceful-fs: 4.2.11 + make-dir: 1.3.0 + pify: 2.3.0 + strip-dirs: 2.1.0 + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + delay@5.0.0: {} + + docker-compose@1.3.0: + dependencies: + yaml: 2.8.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + ejs@3.1.8: + dependencies: + jake: 10.9.4 + + electron-fetch@1.9.1: + dependencies: + encoding: 0.1.13 + + emoji-regex@8.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.3.6: + dependencies: + ansi-colors: 4.1.3 + + err-code@3.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + ethereum-cryptography@2.2.1: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + + eventemitter3@5.0.4: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + eyes@0.1.8: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-levenshtein@3.0.0: + dependencies: + fastest-levenshtein: 1.0.16 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-type@3.9.0: {} + + file-type@5.2.0: {} + + file-type@6.2.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3(supports-color@8.1.1) + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-constants@1.0.0: {} + + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-jetpack@4.3.1: + dependencies: + minimatch: 3.1.5 + rimraf: 2.7.1 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-iterator@1.0.2: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@2.3.1: + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gluegun@5.2.0(debug@4.4.3): + dependencies: + apisauce: 2.1.6(debug@4.4.3) + app-module-path: 2.2.0 + cli-table3: 0.6.0 + colors: 1.4.0 + cosmiconfig: 7.0.1 + cross-spawn: 7.0.3 + ejs: 3.1.8 + enquirer: 2.3.6 + execa: 5.1.1 + fs-jetpack: 4.3.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.lowercase: 4.3.0 + lodash.lowerfirst: 4.3.1 + lodash.pad: 4.5.1 + lodash.padend: 4.6.1 + lodash.padstart: 4.6.1 + lodash.repeat: 4.1.0 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.trim: 4.18.0 + lodash.trimend: 4.18.0 + lodash.trimstart: 4.5.1 + lodash.uppercase: 4.3.0 + lodash.upperfirst: 4.3.1 + ora: 4.0.2 + pluralize: 8.0.0 + semver: 7.3.5 + which: 2.0.2 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - debug + + gopd@1.2.0: {} + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + graphql-import-node@0.0.5(graphql@16.11.0): + dependencies: + graphql: 16.11.0 + + graphql@16.11.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashlru@2.3.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-call@5.3.0: + dependencies: + content-type: 1.0.5 + debug: 4.4.3(supports-color@8.1.1) + is-retry-allowed: 1.2.0 + is-stream: 2.0.1 + parse-json: 4.0.0 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + interface-datastore@8.3.2: + dependencies: + interface-store: 6.0.3 + uint8arrays: 5.1.1 + + interface-store@6.0.3: {} + + ipfs-unixfs@11.2.5: + dependencies: + protons-runtime: 5.6.0 + uint8arraylist: 2.4.9 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-arrayish@0.2.1: {} + + is-callable@1.2.7: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-electron@2.2.2: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-natural-number@4.0.1: {} + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-retry-allowed@1.2.0: {} + + is-stream@1.1.0: {} + + is-stream@2.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iso-url@1.2.1: {} + + isomorphic-ws@4.0.1(ws@7.5.11): + dependencies: + ws: 7.5.11 + + it-all@3.0.11: {} + + it-first@3.0.11: {} + + it-glob@3.0.6: + dependencies: + fast-glob: 3.3.3 + + it-last@3.0.11: {} + + it-map@3.1.6: + dependencies: + it-peekable: 3.0.10 + + it-peekable@3.0.10: {} + + it-pushable@3.2.4: + dependencies: + p-defer: 4.0.1 + + it-stream-types@2.0.4: {} + + it-to-stream@1.0.0: + dependencies: + buffer: 6.0.3 + fast-fifo: 1.3.2 + get-iterator: 1.0.2 + p-defer: 3.0.0 + p-fifo: 1.0.0 + readable-stream: 3.6.2 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jayson@4.2.0: + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.11) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.11 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-stringify-safe@5.0.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kubo-rpc-client@5.4.1(undici@7.16.0): + dependencies: + '@ipld/dag-cbor': 9.2.7 + '@ipld/dag-json': 10.2.9 + '@ipld/dag-pb': 4.1.7 + '@libp2p/crypto': 5.1.19 + '@libp2p/interface': 2.11.0 + '@libp2p/logger': 5.2.0 + '@libp2p/peer-id': 5.1.9 + '@multiformats/multiaddr': 12.5.1 + '@multiformats/multiaddr-to-uri': 11.0.2 + any-signal: 4.2.0 + blob-to-it: 2.0.12 + browser-readablestream-to-it: 2.0.12 + dag-jose: 5.1.1 + electron-fetch: 1.9.1 + err-code: 3.0.1 + ipfs-unixfs: 11.2.5 + iso-url: 1.2.1 + it-all: 3.0.11 + it-first: 3.0.11 + it-glob: 3.0.6 + it-last: 3.0.11 + it-map: 3.1.6 + it-peekable: 3.0.10 + it-to-stream: 1.0.0 + merge-options: 3.0.4 + multiformats: 13.4.2 + nanoid: 5.1.11 + native-fetch: 4.0.2(undici@7.16.0) + parse-duration: 2.1.6 + react-native-fetch-api: 3.0.0 + stream-to-it: 1.0.1 + uint8arrays: 5.1.1 + wherearewe: 2.0.1 + transitivePeerDependencies: + - undici + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash.camelcase@4.3.0: {} + + lodash.kebabcase@4.1.1: {} + + lodash.lowercase@4.3.0: {} + + lodash.lowerfirst@4.3.1: {} + + lodash.pad@4.5.1: {} + + lodash.padend@4.6.1: {} + + lodash.padstart@4.6.1: {} + + lodash.repeat@4.1.0: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.trim@4.18.0: {} + + lodash.trimend@4.18.0: {} + + lodash.trimstart@4.5.1: {} + + lodash.uppercase@4.3.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.18.1: {} + + log-symbols@3.0.0: + dependencies: + chalk: 2.4.2 + + long@5.3.2: {} + + lru-cache@11.5.1: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + main-event@1.0.4: {} + + make-dir@1.3.0: + dependencies: + pify: 3.0.0 + + matchstick-as@0.6.0: + dependencies: + wabt: 1.0.24 + + math-intrinsics@1.1.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + ms@4.0.0-nightly.202508271359: {} + + multiformats@13.1.3: {} + + multiformats@13.4.2: {} + + multiformats@14.0.0: {} + + mute-stream@2.0.0: {} + + nanoid@5.1.11: {} + + native-fetch@4.0.2(undici@7.16.0): + dependencies: + undici: 7.16.0 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + ora@4.0.2: + dependencies: + chalk: 2.4.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + log-symbols: 3.0.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + p-defer@3.0.0: {} + + p-defer@4.0.1: {} + + p-fifo@1.0.0: + dependencies: + fast-fifo: 1.3.2 + p-defer: 3.0.0 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-duration@2.1.6: {} + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-type@4.0.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + + pluralize@8.0.0: {} + + possible-typed-array-names@1.1.0: {} + + prettier@3.6.2: {} + + process-nextick-args@2.0.1: {} + + progress-events@1.1.0: {} + + progress@2.0.3: {} + + proto-list@1.2.4: {} + + protons-runtime@5.6.0: + dependencies: + uint8-varint: 2.0.5 + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + protons-runtime@6.0.2: + dependencies: + uint8-varint: 2.0.5 + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + queue-microtask@1.2.3: {} + + react-native-fetch-api@3.0.0: + dependencies: + p-defer: 3.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.2 + + resolve-from@4.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + seek-bzip@1.0.6: + dependencies: + commander: 2.20.3 + + semver@7.3.5: + dependencies: + lru-cache: 6.0.0 + + semver@7.7.3: {} + + semver@7.8.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + stream-to-it@1.0.1: + dependencies: + it-stream-types: 2.0.4 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-dirs@2.1.0: + dependencies: + is-natural-number: 4.0.1 + + strip-final-newline@2.0.0: {} + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-stream@1.6.2: + dependencies: + bl: 1.2.3 + buffer-alloc: 1.2.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + readable-stream: 2.3.8 + to-buffer: 1.2.2 + xtend: 4.0.2 + + through@2.3.8: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-fest@0.21.3: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + uint8-varint@2.0.5: + dependencies: + uint8arraylist: 2.4.9 + uint8arrays: 5.1.1 + + uint8-varint@3.0.0: + dependencies: + uint8arraylist: 3.0.2 + uint8arrays: 6.1.1 + + uint8arraylist@2.4.9: + dependencies: + uint8arrays: 5.1.1 + + uint8arraylist@3.0.2: + dependencies: + uint8arrays: 6.1.1 + + uint8arrays@5.1.1: + dependencies: + multiformats: 13.4.2 + + uint8arrays@6.1.1: + dependencies: + multiformats: 14.0.0 + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + undici-types@7.24.6: {} + + undici@7.16.0: {} + + universalify@2.0.1: {} + + urlpattern-polyfill@10.1.0: {} + + utf8-codec@1.0.0: {} + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.22 + + uuid@8.3.2: {} + + wabt@1.0.24: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + weald@1.1.3: + dependencies: + ms: 4.0.0-nightly.202508271359 + supports-color: 10.2.2 + + web3-errors@1.3.1: + dependencies: + web3-types: 1.10.0 + + web3-eth-abi@4.4.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + abitype: 0.7.1(typescript@5.9.3)(zod@3.25.76) + web3-errors: 1.3.1 + web3-types: 1.10.0 + web3-utils: 4.3.3 + web3-validator: 2.0.6 + transitivePeerDependencies: + - typescript + - zod + + web3-types@1.10.0: {} + + web3-utils@4.3.3: + dependencies: + ethereum-cryptography: 2.2.1 + eventemitter3: 5.0.4 + web3-errors: 1.3.1 + web3-types: 1.10.0 + web3-validator: 2.0.6 + + web3-validator@2.0.6: + dependencies: + ethereum-cryptography: 2.2.1 + util: 0.12.5 + web3-errors: 1.3.1 + web3-types: 1.10.0 + zod: 3.25.76 + + wherearewe@2.0.1: + dependencies: + is-electron: 2.2.2 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@7.5.11: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xtend@4.0.2: {} + + yallist@4.0.0: {} + + yaml@1.10.3: {} + + yaml@2.8.1: {} + + yargs-parser@21.1.1: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yoctocolors-cjs@2.1.3: {} + + zod@3.25.76: {} diff --git a/points-indexer/schema.graphql b/points-indexer/schema.graphql new file mode 100644 index 0000000..d523e52 --- /dev/null +++ b/points-indexer/schema.graphql @@ -0,0 +1,106 @@ +# Points leaderboard + mint mirror. +# +# The POINTS (HP) ERC20 is the canonical balance. This subgraph is NOT the source +# of truth; it serves two purposes (design §7): +# 1. Live leaderboard — query `UserPoints(orderBy: total, orderDirection: desc)`. +# 2. Mirror — `total` tracks the on-chain HP `Transfer` mints/burns exactly, while +# the per-category breakdown (maker / taker / keeper) is derived from the +# PointsHook events that accompany each mint. + +# ── Program root entity (singleton, id = "0") ─────────────────────────────── +type PointsProgram @entity(immutable: false) { + "Singleton entity, id is always the literal string \"0\"." + id: String! + + # Contract identity + pointsToken: Bytes! + hook: Bytes! + redeemer: Bytes! + + # Canonical mirror (from HP Transfer events) + totalPoints: BigInt! # current circulating HP (mirrors totalSupply) + totalMinted: BigInt! # cumulative HP ever minted + totalBurned: BigInt! # cumulative HP burned via redemption + finalized: Boolean! # true once HP.finalize() has frozen minting + + # Category breakdown (from PointsHook events) + makerPoints: BigInt! + takerPoints: BigInt! + keeperPoints: BigInt! + + # Redemption (from PointsRedeemer events) + totalRedeemedPoints: BigInt! + totalGovDistributed: BigInt! + + # Counters + totalUsers: Int! + fillCount: Int! + liquidationCount: Int! + redemptionCount: Int! + + lastUpdatedAt: BigInt! +} + +# ── Per-user leaderboard row ──────────────────────────────────────────────── +type UserPoints @entity(immutable: false) { + id: Bytes! # user address + address: Bytes! + + "Current HP balance (mirrors `points.balanceOf(address)`); the leaderboard sort key." + total: BigInt! + "Cumulative HP ever minted to this account (does not decrease on redemption)." + totalEarned: BigInt! + + # Category breakdown of `totalEarned` + makerPoints: BigInt! + takerPoints: BigInt! + keeperPoints: BigInt! + + # Redemption + redeemedPoints: BigInt! + govReceived: BigInt! + + # Counters + fillCount: Int! + liquidationCount: Int! + + # Relations + mints: [PointsMint!]! @derivedFrom(field: "user") + redemptions: [PointsRedemption!]! @derivedFrom(field: "user") + + firstSeenAt: BigInt! + lastActivityAt: BigInt! +} + +# ── Category of a mint, from the PointsHook event that produced it ─────────── +enum PointsCategory { + MAKER + TAKER + KEEPER +} + +# ── Individual mint (PointsHook FillPointsMinted / KeeperPointsMinted) ─────── +type PointsMint @entity(immutable: true) { + id: Bytes! # tx hash + log index + user: UserPoints! + amount: BigInt! + category: PointsCategory! + + timestamp: BigInt! + blockNumber: BigInt! + transactionHash: Bytes! +} + +# ── Redemption (PointsRedeemer Swapped) ───────────────────────────────────── +type PointsRedemption @entity(immutable: true) { + id: Bytes! # tx hash + log index + user: UserPoints! + pointsBurned: BigInt! + govAmount: BigInt! + liquidAmount: BigInt! + escrowAmount: BigInt! + + timestamp: BigInt! + blockNumber: BigInt! + transactionHash: Bytes! +} diff --git a/points-indexer/src/ids.ts b/points-indexer/src/ids.ts new file mode 100644 index 0000000..64f7d13 --- /dev/null +++ b/points-indexer/src/ids.ts @@ -0,0 +1,6 @@ +import { BigInt, Bytes } from "@graphprotocol/graph-ts"; + +/** Stable per-log identifier: `transactionHash || logIndex` (5-byte i32 suffix). */ +export function createEventId(transactionHash: Bytes, logIndex: BigInt): Bytes { + return transactionHash.concatI32(logIndex.toI32()); +} diff --git a/points-indexer/src/points.ts b/points-indexer/src/points.ts new file mode 100644 index 0000000..c42cae3 --- /dev/null +++ b/points-indexer/src/points.ts @@ -0,0 +1,236 @@ +import { Address, BigInt, Bytes, dataSource } from "@graphprotocol/graph-ts"; +import { Finalized, Transfer } from "../generated/Points/Points"; +import { FillPointsMinted, KeeperPointsMinted } from "../generated/PointsHook/PointsHook"; +import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; +import { PointsMint, PointsProgram, PointsRedemption, UserPoints } from "../generated/schema"; +import { createEventId } from "./ids"; + +const ZERO_ADDRESS = Address.zero(); + +// PointsCategory enum string values (must match schema.graphql). +const CATEGORY_MAKER = "MAKER"; +const CATEGORY_TAKER = "TAKER"; +const CATEGORY_KEEPER = "KEEPER"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function getOrCreateProgram(): PointsProgram { + let program = PointsProgram.load("0"); + if (!program) { + program = new PointsProgram("0"); + program.pointsToken = Bytes.empty(); + program.hook = Bytes.empty(); + program.redeemer = Bytes.empty(); + program.totalPoints = BigInt.zero(); + program.totalMinted = BigInt.zero(); + program.totalBurned = BigInt.zero(); + program.finalized = false; + program.makerPoints = BigInt.zero(); + program.takerPoints = BigInt.zero(); + program.keeperPoints = BigInt.zero(); + program.totalRedeemedPoints = BigInt.zero(); + program.totalGovDistributed = BigInt.zero(); + program.totalUsers = 0; + program.fillCount = 0; + program.liquidationCount = 0; + program.redemptionCount = 0; + program.lastUpdatedAt = BigInt.zero(); + } + return program; +} + +/** + * Returns the leaderboard row, creating it on first sight and bumping the program + * user count. Caller is responsible for saving both entities. + */ +function getOrCreateUser(address: Address, timestamp: BigInt, program: PointsProgram): UserPoints { + let user = UserPoints.load(address); + if (!user) { + user = new UserPoints(address); + user.address = address; + user.total = BigInt.zero(); + user.totalEarned = BigInt.zero(); + user.makerPoints = BigInt.zero(); + user.takerPoints = BigInt.zero(); + user.keeperPoints = BigInt.zero(); + user.redeemedPoints = BigInt.zero(); + user.govReceived = BigInt.zero(); + user.fillCount = 0; + user.liquidationCount = 0; + user.firstSeenAt = timestamp; + user.lastActivityAt = timestamp; + program.totalUsers += 1; + } + return user; +} + +function recordMint( + user: UserPoints, + amount: BigInt, + category: string, + transactionHash: Bytes, + logIndex: BigInt, + blockNumber: BigInt, + timestamp: BigInt, +): void { + const mint = new PointsMint(createEventId(transactionHash, logIndex)); + mint.user = user.id; + mint.amount = amount; + mint.category = category; + mint.timestamp = timestamp; + mint.blockNumber = blockNumber; + mint.transactionHash = transactionHash; + mint.save(); +} + +// ── Points token: canonical balance mirror ────────────────────────────────── +// +// POINTS (HP) blocks user-to-user transfers, so Transfer events are only mints +// (from == 0x0, attribution) and burns (to == 0x0, redemption). That makes the +// stream a lossless ledger for `total` / `totalSupply`. +export function handleTransfer(event: Transfer): void { + const from = event.params.from; + const to = event.params.to; + const amount = event.params.value; + + const program = getOrCreateProgram(); + if (program.pointsToken.equals(Bytes.empty())) { + program.pointsToken = dataSource.address(); + } + + if (from.equals(ZERO_ADDRESS)) { + // Mint (attribution). + program.totalMinted = program.totalMinted.plus(amount); + program.totalPoints = program.totalPoints.plus(amount); + + const user = getOrCreateUser(to, event.block.timestamp, program); + user.total = user.total.plus(amount); + user.totalEarned = user.totalEarned.plus(amount); + user.lastActivityAt = event.block.timestamp; + user.save(); + } else if (to.equals(ZERO_ADDRESS)) { + // Burn (redemption). + program.totalBurned = program.totalBurned.plus(amount); + program.totalPoints = program.totalPoints.minus(amount); + + const user = getOrCreateUser(from, event.block.timestamp, program); + user.total = user.total.minus(amount); + user.lastActivityAt = event.block.timestamp; + user.save(); + } + + program.lastUpdatedAt = event.block.timestamp; + program.save(); +} + +export function handleFinalized(event: Finalized): void { + const program = getOrCreateProgram(); + program.finalized = true; + program.lastUpdatedAt = event.block.timestamp; + program.save(); +} + +// ── PointsHook: per-category breakdown ────────────────────────────────────── + +export function handleFillPointsMinted(event: FillPointsMinted): void { + const program = getOrCreateProgram(); + if (program.hook.equals(Bytes.empty())) { + program.hook = dataSource.address(); + } + + const amount = event.params.amount; + const user = getOrCreateUser(event.params.account, event.block.timestamp, program); + + let category: string; + if (event.params.isMaker) { + user.makerPoints = user.makerPoints.plus(amount); + program.makerPoints = program.makerPoints.plus(amount); + category = CATEGORY_MAKER; + } else { + user.takerPoints = user.takerPoints.plus(amount); + program.takerPoints = program.takerPoints.plus(amount); + category = CATEGORY_TAKER; + } + + user.fillCount += 1; + user.lastActivityAt = event.block.timestamp; + user.save(); + + program.fillCount += 1; + program.lastUpdatedAt = event.block.timestamp; + program.save(); + + recordMint( + user, + amount, + category, + event.transaction.hash, + event.logIndex, + event.block.number, + event.block.timestamp, + ); +} + +export function handleKeeperPointsMinted(event: KeeperPointsMinted): void { + const program = getOrCreateProgram(); + if (program.hook.equals(Bytes.empty())) { + program.hook = dataSource.address(); + } + + const amount = event.params.amount; + const user = getOrCreateUser(event.params.liquidator, event.block.timestamp, program); + user.keeperPoints = user.keeperPoints.plus(amount); + user.liquidationCount += 1; + user.lastActivityAt = event.block.timestamp; + user.save(); + + program.keeperPoints = program.keeperPoints.plus(amount); + program.liquidationCount += 1; + program.lastUpdatedAt = event.block.timestamp; + program.save(); + + recordMint( + user, + amount, + CATEGORY_KEEPER, + event.transaction.hash, + event.logIndex, + event.block.number, + event.block.timestamp, + ); +} + +// ── PointsRedeemer: POINTS → GOV swaps ────────────────────────────────────── + +export function handleSwapped(event: Swapped): void { + const program = getOrCreateProgram(); + if (program.redeemer.equals(Bytes.empty())) { + program.redeemer = dataSource.address(); + } + + const pointsBurned = event.params.pointsBurned; + const govAmount = event.params.govAmount; + + const user = getOrCreateUser(event.params.user, event.block.timestamp, program); + user.redeemedPoints = user.redeemedPoints.plus(pointsBurned); + user.govReceived = user.govReceived.plus(govAmount); + user.lastActivityAt = event.block.timestamp; + user.save(); + + program.totalRedeemedPoints = program.totalRedeemedPoints.plus(pointsBurned); + program.totalGovDistributed = program.totalGovDistributed.plus(govAmount); + program.redemptionCount += 1; + program.lastUpdatedAt = event.block.timestamp; + program.save(); + + const redemption = new PointsRedemption(createEventId(event.transaction.hash, event.logIndex)); + redemption.user = user.id; + redemption.pointsBurned = pointsBurned; + redemption.govAmount = govAmount; + redemption.liquidAmount = event.params.liquidAmount; + redemption.escrowAmount = event.params.escrowAmount; + redemption.timestamp = event.block.timestamp; + redemption.blockNumber = event.block.number; + redemption.transactionHash = event.transaction.hash; + redemption.save(); +} diff --git a/points-indexer/subgraph.template.yaml b/points-indexer/subgraph.template.yaml new file mode 100644 index 0000000..71d322d --- /dev/null +++ b/points-indexer/subgraph.template.yaml @@ -0,0 +1,86 @@ +# Use subgraph.template.yaml to add changes to the subgraph.yaml file. +# Variables are substituted via envsubst from environment variables. +# +# Hard precondition (design §7): the POINTS token, PointsHook and PointsRedeemer +# must all be deployed on the SAME network with finalized addresses. A single +# subgraph cannot index across networks. +specVersion: 1.3.0 +indexerHints: + prune: auto +schema: + file: ./schema.graphql +dataSources: + # ── Canonical mirror: HP balances from Transfer (mint/burn) ─────────────── + - kind: ethereum + name: Points + network: "${NETWORK}" + source: + address: "${POINTS_ADDRESS}" + startBlock: ${POINTS_START_BLOCK} + abi: Points + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + entities: + - PointsProgram + - UserPoints + abis: + - name: Points + file: ../contracts/abi/Points.json + eventHandlers: + - event: Transfer(indexed address,indexed address,uint256) + handler: handleTransfer + - event: Finalized() + handler: handleFinalized + file: ./src/points.ts + + # ── Category breakdown: maker / taker / keeper from the hook ─────────────── + - kind: ethereum + name: PointsHook + network: "${NETWORK}" + source: + address: "${HOOK_ADDRESS}" + startBlock: ${HOOK_START_BLOCK} + abi: PointsHook + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + entities: + - PointsProgram + - UserPoints + - PointsMint + abis: + - name: PointsHook + file: ../contracts/abi/PointsHook.json + eventHandlers: + - event: FillPointsMinted(indexed address,uint256,bool) + handler: handleFillPointsMinted + - event: KeeperPointsMinted(indexed address,uint256) + handler: handleKeeperPointsMinted + file: ./src/points.ts + + # ── Redemption: POINTS → GOV swaps ──────────────────────────────────────── + - kind: ethereum + name: PointsRedeemer + network: "${NETWORK}" + source: + address: "${REDEEMER_ADDRESS}" + startBlock: ${REDEEMER_START_BLOCK} + abi: PointsRedeemer + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + entities: + - PointsProgram + - UserPoints + - PointsRedemption + abis: + - name: PointsRedeemer + file: ../contracts/abi/PointsRedeemer.json + eventHandlers: + - event: Swapped(indexed address,uint256,uint256,uint256,uint256) + handler: handleSwapped + file: ./src/points.ts diff --git a/points-indexer/tests/helpers.ts b/points-indexer/tests/helpers.ts new file mode 100644 index 0000000..1356831 --- /dev/null +++ b/points-indexer/tests/helpers.ts @@ -0,0 +1,49 @@ +/** + * Deterministic test data generators and event param helpers. + * AssemblyScript has no Math.random, so we use seeds for reproducible, meaningful IDs. + */ +import { Address, BigInt, Bytes, DataSourceContext, ethereum } from "@graphprotocol/graph-ts"; +import { dataSourceMock } from "matchstick-as/assembly/index"; + +function padLeft(s: string, len: i32, char: string): string { + while (s.length < len) { + s = char + s; + } + return s; +} + +/** Deterministic address from numeric id. e.g. userAddress(1) => 0x00...01 */ +export function userAddress(id: i32): Address { + const hex = padLeft(id.toString(16), 40, "0"); + return Address.fromString("0x" + hex); +} + +export const POINTS_ADDRESS = userAddress(255); +export const HOOK_ADDRESS = userAddress(254); +export const REDEEMER_ADDRESS = userAddress(253); + +/** + * Point `dataSource.address()` at one of the points contracts. The handlers all + * live in one mapping file, so set the address that matches the event under test. + */ +export function mockDataSource(address: Address): void { + dataSourceMock.setAddressAndContext(address.toHexString(), new DataSourceContext()); +} + +// ── ethereum.EventParam helpers ───────────────────────────────────────────── + +export function paramAddr(name: string, value: Address): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromAddress(value)); +} + +export function paramUint(name: string, value: BigInt): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(value)); +} + +export function paramBool(name: string, value: boolean): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromBoolean(value)); +} + +export function paramBytes(name: string, value: Bytes): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromBytes(value)); +} diff --git a/points-indexer/tests/points.test.ts b/points-indexer/tests/points.test.ts new file mode 100644 index 0000000..e6a2377 --- /dev/null +++ b/points-indexer/tests/points.test.ts @@ -0,0 +1,196 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; +import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; +import { Finalized, Transfer } from "../generated/Points/Points"; +import { FillPointsMinted, KeeperPointsMinted } from "../generated/PointsHook/PointsHook"; +import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; +import { + handleFillPointsMinted, + handleFinalized, + handleKeeperPointsMinted, + handleSwapped, + handleTransfer, +} from "../src/points"; +import { + HOOK_ADDRESS, + POINTS_ADDRESS, + REDEEMER_ADDRESS, + mockDataSource, + paramAddr, + paramBool, + paramUint, + userAddress, +} from "./helpers"; + +const ZERO = Address.zero(); + +function transferEvent(from: Address, to: Address, value: BigInt): Transfer { + return newTypedMockEventWithParams([ + paramAddr("from", from), + paramAddr("to", to), + paramUint("value", value), + ]); +} + +function fillEvent(account: Address, amount: BigInt, isMaker: boolean): FillPointsMinted { + return newTypedMockEventWithParams([ + paramAddr("account", account), + paramUint("amount", amount), + paramBool("isMaker", isMaker), + ]); +} + +function keeperEvent(liquidator: Address, amount: BigInt): KeeperPointsMinted { + return newTypedMockEventWithParams([ + paramAddr("liquidator", liquidator), + paramUint("amount", amount), + ]); +} + +function swappedEvent( + user: Address, + pointsBurned: BigInt, + govAmount: BigInt, + liquidAmount: BigInt, + escrowAmount: BigInt, +): Swapped { + return newTypedMockEventWithParams([ + paramAddr("user", user), + paramUint("pointsBurned", pointsBurned), + paramUint("govAmount", govAmount), + paramUint("liquidAmount", liquidAmount), + paramUint("escrowAmount", escrowAmount), + ]); +} + +describe("Points mirror (Transfer)", () => { + beforeEach(() => { + clearStore(); + mockDataSource(POINTS_ADDRESS); + }); + + test("mint credits balance, totalEarned, and program totals", () => { + const alice = userAddress(1); + handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + + assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1000000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1000000"); + assert.fieldEquals("PointsProgram", "0", "totalPoints", "1000000"); + assert.fieldEquals("PointsProgram", "0", "totalMinted", "1000000"); + assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); + }); + + test("burn debits balance and shrinks circulating supply", () => { + const alice = userAddress(1); + handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + handleTransfer(transferEvent(alice, ZERO, BigInt.fromI32(400_000))); + + assert.fieldEquals("UserPoints", alice.toHexString(), "total", "600000"); + // totalEarned does not decrease on burn. + assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1000000"); + assert.fieldEquals("PointsProgram", "0", "totalPoints", "600000"); + assert.fieldEquals("PointsProgram", "0", "totalBurned", "400000"); + }); + + test("finalize flips the program flag", () => { + handleFinalized(newTypedMockEventWithParams([])); + assert.fieldEquals("PointsProgram", "0", "finalized", "true"); + }); +}); + +describe("Category breakdown (PointsHook)", () => { + beforeEach(() => { + clearStore(); + mockDataSource(HOOK_ADDRESS); + }); + + test("maker fill credits makerPoints and records a MAKER mint", () => { + const alice = userAddress(1); + const evt = fillEvent(alice, BigInt.fromI32(1_500_000), true); + handleFillPointsMinted(evt); + + assert.fieldEquals("UserPoints", alice.toHexString(), "makerPoints", "1500000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "takerPoints", "0"); + assert.fieldEquals("UserPoints", alice.toHexString(), "fillCount", "1"); + assert.fieldEquals("PointsProgram", "0", "makerPoints", "1500000"); + assert.fieldEquals("PointsProgram", "0", "fillCount", "1"); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("PointsMint", id, "category", "MAKER"); + assert.fieldEquals("PointsMint", id, "amount", "1500000"); + }); + + test("taker fill credits takerPoints", () => { + const bob = userAddress(2); + handleFillPointsMinted(fillEvent(bob, BigInt.fromI32(1_000_000), false)); + assert.fieldEquals("UserPoints", bob.toHexString(), "takerPoints", "1000000"); + assert.fieldEquals("PointsProgram", "0", "takerPoints", "1000000"); + }); + + test("liquidation credits keeperPoints and records a KEEPER mint", () => { + const keeper = userAddress(3); + const evt = keeperEvent(keeper, BigInt.fromI32(5_000_000)); + handleKeeperPointsMinted(evt); + + assert.fieldEquals("UserPoints", keeper.toHexString(), "keeperPoints", "5000000"); + assert.fieldEquals("UserPoints", keeper.toHexString(), "liquidationCount", "1"); + assert.fieldEquals("PointsProgram", "0", "keeperPoints", "5000000"); + assert.fieldEquals("PointsProgram", "0", "liquidationCount", "1"); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("PointsMint", id, "category", "KEEPER"); + }); +}); + +describe("Redemption (PointsRedeemer)", () => { + beforeEach(() => { + clearStore(); + mockDataSource(REDEEMER_ADDRESS); + }); + + test("swap records redeemed points and GOV received", () => { + const alice = userAddress(1); + const evt = swappedEvent( + alice, + BigInt.fromI32(1_000_000), + BigInt.fromI32(2_000_000), + BigInt.fromI32(1_000_000), + BigInt.fromI32(1_000_000), + ); + handleSwapped(evt); + + assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "1000000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "govReceived", "2000000"); + assert.fieldEquals("PointsProgram", "0", "totalRedeemedPoints", "1000000"); + assert.fieldEquals("PointsProgram", "0", "totalGovDistributed", "2000000"); + assert.fieldEquals("PointsProgram", "0", "redemptionCount", "1"); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("PointsRedemption", id, "pointsBurned", "1000000"); + assert.fieldEquals("PointsRedemption", id, "escrowAmount", "1000000"); + }); +}); + +describe("End-to-end reconciliation", () => { + beforeEach(() => { + clearStore(); + }); + + test("mirror total and category breakdown reconcile for one fill", () => { + const alice = userAddress(1); + + // Token mint (canonical balance) ... + mockDataSource(POINTS_ADDRESS); + handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_500_000))); + + // ... accompanied by the hook's maker attribution in the same logical tx. + mockDataSource(HOOK_ADDRESS); + handleFillPointsMinted(fillEvent(alice, BigInt.fromI32(1_500_000), true)); + + assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1500000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1500000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "makerPoints", "1500000"); + // Only one distinct user across both data sources. + assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); + }); +}); diff --git a/points-indexer/tsconfig.json b/points-indexer/tsconfig.json new file mode 100644 index 0000000..2a368e9 --- /dev/null +++ b/points-indexer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noEmitOnError": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "target": "esnext", + "module": "commonjs", + "noLib": true, + "allowJs": false, + "skipLibCheck": true, + "typeRoots": ["./node_modules/assemblyscript/std/types"], + "types": ["assembly"], + "paths": { + "*": ["./node_modules/assemblyscript/std/types/assembly/*"] + } + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "generated", "build"] +} diff --git a/points-indexer/types/ambient.d.ts b/points-indexer/types/ambient.d.ts new file mode 100644 index 0000000..5147519 --- /dev/null +++ b/points-indexer/types/ambient.d.ts @@ -0,0 +1,9 @@ +// Stubs for TS-only globals that aren't defined by `assemblyscript/std/types/assembly`. +// Required because we run with `noLib: true` (AssemblyScript types own the global namespace); +// without these, the IDE/tsc emits TS2318 ("Cannot find global type ...") whenever code touches +// function-typed values (e.g. matchstick-as test helpers, graph-ts callbacks). +// +// Pure type-space additions — AssemblyScript's compiler ignores `.d.ts` files. + +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} From 3f20c4aec7cdcd494ebbc4925b6f4d48fe392f74 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 8 Jun 2026 15:07:31 +0200 Subject: [PATCH 059/180] docs(points): note unplug-before-finalize ordering on hook and finalize() --- contracts/contracts/Points.sol | 4 ++++ contracts/contracts/PointsHook.sol | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/contracts/contracts/Points.sol b/contracts/contracts/Points.sol index 179dedd..324936e 100644 --- a/contracts/contracts/Points.sol +++ b/contracts/contracts/Points.sol @@ -123,6 +123,10 @@ contract Points is IERC20, IERC20Metadata, IPoints, AccessControl { // ── Lifecycle ──────────────────────────────────────────────────────────── /// @notice Permanently freeze minting and open redemption. Admin-only, one-way. + /// @dev OPERATIONAL ORDERING: unplug the hook from every venue first + /// (`setHook(address(0))` on perps and futures). After finalize, `mint` reverts + /// forever, so any venue still routing fills/liquidations through `PointsHook` → + /// `mint` would revert on every trade and liquidation. Unplug, then finalize. function finalize() external onlyRole(DEFAULT_ADMIN_ROLE) notFinalized { finalized = true; emit Finalized(); diff --git a/contracts/contracts/PointsHook.sol b/contracts/contracts/PointsHook.sol index e940239..861a3e3 100644 --- a/contracts/contracts/PointsHook.sol +++ b/contracts/contracts/PointsHook.sol @@ -18,6 +18,13 @@ import {IPoints} from "./interfaces/IPoints.sol"; /// - self-match exclusion (`maker == taker` mints nothing), /// - per-side minimum fee threshold (dust trades earn nothing), /// - the positive-fees invariant enforced by the venue config. +/// +/// OPERATIONAL ORDERING — wind down before `finalize()`: once the POINTS token is +/// `finalize()`d, `mint` reverts permanently. Each venue's `onFill` / `onLiquidation` +/// routes through this hook into `points.mint`, so the hook MUST be unplugged from +/// every venue (`setHook(address(0))` on perps and futures) BEFORE calling +/// `Points.finalize()`. Finalizing while a venue still points here would make every +/// fill and liquidation revert into the hook on each `mint`. contract PointsHook is IPointsHook, AccessControl { /// @notice Granted only to the venue contracts allowed to drive accrual. bytes32 public constant HOOK_CALLER_ROLE = keccak256("HOOK_CALLER_ROLE"); From 984b6ef4c216ef8b5a3f4b5d9a5295de01c44a47 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 9 Jun 2026 15:45:44 +0200 Subject: [PATCH 060/180] feat(points): drop hook accrual events, mirror mints from Transfer PointsHook no longer emits FillPointsMinted/KeeperPointsMinted; every accrual is mirrored from the POINTS Transfer (mint) stream instead. The subgraph drops the PointsHook data source and category breakdown, counts each mint (mintCount on UserPoints + PointsProgram), and records a PointsMint per Transfer mint. Add points-indexer integration tests (hardhat-matchstick-ts) that drive the real Points/PointsHook/PointsRedeemer contracts through the mappings, mirroring the futures-marketplace/indexer/integration harness. --- contracts/abi/ContractErrors.json | 24 +- contracts/abi/ContractErrors.ts | 24 +- contracts/abi/IPoints.json | 152 +-- contracts/abi/IPoints.ts | 152 +-- contracts/abi/Points.json | 131 +-- contracts/abi/Points.ts | 131 +-- contracts/abi/PointsHook.json | 341 ------- contracts/abi/PointsHook.ts | 341 ------- contracts/contracts/PointsHook.sol | 8 +- contracts/tests/pointsHook.test.ts | 8 +- contracts/tests/pointsIntegrationFixtures.ts | 129 +++ docs/points-system-design.md | 47 +- points-indexer/.env.example | 8 +- points-indexer/.gitignore | 8 + points-indexer/README.md | 43 +- points-indexer/hardhat.config.ts | 29 + .../integration/points-mint.test.ts | 170 ++++ .../integration/points-redemption.test.ts | 98 ++ points-indexer/integration/tsconfig.json | 8 + points-indexer/package.json | 19 +- points-indexer/pnpm-lock.yaml | 951 ++++++++++++++++++ points-indexer/schema.graphql | 34 +- .../scripts/patch-matchstick-ts.mjs | 97 ++ points-indexer/src/points.ts | 121 +-- points-indexer/subgraph.template.yaml | 36 +- points-indexer/tests/helpers.ts | 5 - points-indexer/tests/points.test.ts | 122 +-- 27 files changed, 1708 insertions(+), 1529 deletions(-) create mode 100644 contracts/tests/pointsIntegrationFixtures.ts create mode 100644 points-indexer/hardhat.config.ts create mode 100644 points-indexer/integration/points-mint.test.ts create mode 100644 points-indexer/integration/points-redemption.test.ts create mode 100644 points-indexer/integration/tsconfig.json create mode 100644 points-indexer/scripts/patch-matchstick-ts.mjs diff --git a/contracts/abi/ContractErrors.json b/contracts/abi/ContractErrors.json index 9ac3a06..c198cb3 100644 --- a/contracts/abi/ContractErrors.json +++ b/contracts/abi/ContractErrors.json @@ -57,11 +57,6 @@ "name": "ReentrancyGuardReentrantCall", "type": "error" }, - { - "inputs": [], - "name": "AlreadyFinalized", - "type": "error" - }, { "inputs": [ { @@ -100,11 +95,6 @@ "name": "InsufficientGov", "type": "error" }, - { - "inputs": [], - "name": "InvalidReferrer", - "type": "error" - }, { "inputs": [], "name": "AccessControlBadConfirmation", @@ -117,12 +107,12 @@ }, { "inputs": [], - "name": "MarginBreach", + "name": "TransfersDisabled", "type": "error" }, { "inputs": [], - "name": "TransferNotAllowed", + "name": "MarginBreach", "type": "error" }, { @@ -136,11 +126,6 @@ "name": "ERC20InvalidSpender", "type": "error" }, - { - "inputs": [], - "name": "ReferrerAlreadySet", - "type": "error" - }, { "inputs": [ { @@ -273,6 +258,11 @@ "name": "AlreadyEnabled", "type": "error" }, + { + "inputs": [], + "name": "InsufficientBalance", + "type": "error" + }, { "inputs": [], "name": "MintingFinalized", diff --git a/contracts/abi/ContractErrors.ts b/contracts/abi/ContractErrors.ts index b88c1de..27bf527 100644 --- a/contracts/abi/ContractErrors.ts +++ b/contracts/abi/ContractErrors.ts @@ -57,11 +57,6 @@ export const contractErrors = [ "name": "ReentrancyGuardReentrantCall", "type": "error" }, - { - "inputs": [], - "name": "AlreadyFinalized", - "type": "error" - }, { "inputs": [ { @@ -100,11 +95,6 @@ export const contractErrors = [ "name": "InsufficientGov", "type": "error" }, - { - "inputs": [], - "name": "InvalidReferrer", - "type": "error" - }, { "inputs": [], "name": "AccessControlBadConfirmation", @@ -117,12 +107,12 @@ export const contractErrors = [ }, { "inputs": [], - "name": "MarginBreach", + "name": "TransfersDisabled", "type": "error" }, { "inputs": [], - "name": "TransferNotAllowed", + "name": "MarginBreach", "type": "error" }, { @@ -136,11 +126,6 @@ export const contractErrors = [ "name": "ERC20InvalidSpender", "type": "error" }, - { - "inputs": [], - "name": "ReferrerAlreadySet", - "type": "error" - }, { "inputs": [ { @@ -273,6 +258,11 @@ export const contractErrors = [ "name": "AlreadyEnabled", "type": "error" }, + { + "inputs": [], + "name": "InsufficientBalance", + "type": "error" + }, { "inputs": [], "name": "MintingFinalized", diff --git a/contracts/abi/IPoints.json b/contracts/abi/IPoints.json index 007ab10..9bb566b 100644 --- a/contracts/abi/IPoints.json +++ b/contracts/abi/IPoints.json @@ -1,68 +1,13 @@ [ { - "anonymous": false, "inputs": [ { - "indexed": true, "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Approval", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Transfer", - "type": "event" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "internalType": "address", - "name": "spender", + "name": "account", "type": "address" } ], - "name": "allowance", + "name": "balanceOf", "outputs": [ { "internalType": "uint256", @@ -77,47 +22,9 @@ "inputs": [ { "internalType": "address", - "name": "spender", + "name": "from", "type": "address" }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "approve", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "balanceOf", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ { "internalType": "uint256", "name": "amount", @@ -172,58 +79,5 @@ ], "stateMutability": "view", "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "transfer", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "transferFrom", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" } ] diff --git a/contracts/abi/IPoints.ts b/contracts/abi/IPoints.ts index d756286..1b1e333 100644 --- a/contracts/abi/IPoints.ts +++ b/contracts/abi/IPoints.ts @@ -1,68 +1,13 @@ export const IPointsAbi = [ { - "anonymous": false, "inputs": [ { - "indexed": true, "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Approval", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "Transfer", - "type": "event" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "internalType": "address", - "name": "spender", + "name": "account", "type": "address" } ], - "name": "allowance", + "name": "balanceOf", "outputs": [ { "internalType": "uint256", @@ -77,47 +22,9 @@ export const IPointsAbi = [ "inputs": [ { "internalType": "address", - "name": "spender", + "name": "from", "type": "address" }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "approve", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "balanceOf", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ { "internalType": "uint256", "name": "amount", @@ -172,58 +79,5 @@ export const IPointsAbi = [ ], "stateMutability": "view", "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "transfer", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - } - ], - "name": "transferFrom", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" } ] as const; diff --git a/contracts/abi/Points.json b/contracts/abi/Points.json index 5db6974..47fd1f8 100644 --- a/contracts/abi/Points.json +++ b/contracts/abi/Points.json @@ -33,93 +33,7 @@ }, { "inputs": [], - "name": "AlreadyFinalized", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "allowance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientAllowance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "balance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientBalance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "approver", - "type": "address" - } - ], - "name": "ERC20InvalidApprover", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "receiver", - "type": "address" - } - ], - "name": "ERC20InvalidReceiver", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "ERC20InvalidSender", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - } - ], - "name": "ERC20InvalidSpender", + "name": "InsufficientBalance", "type": "error" }, { @@ -129,7 +43,7 @@ }, { "inputs": [], - "name": "TransferNotAllowed", + "name": "TransfersDisabled", "type": "error" }, { @@ -270,7 +184,7 @@ }, { "inputs": [], - "name": "DEFAULT_ADMIN_ROLE", + "name": "BURNER_ROLE", "outputs": [ { "internalType": "bytes32", @@ -283,7 +197,7 @@ }, { "inputs": [], - "name": "MINTER_ROLE", + "name": "DEFAULT_ADMIN_ROLE", "outputs": [ { "internalType": "bytes32", @@ -296,7 +210,7 @@ }, { "inputs": [], - "name": "TRANSFER_ROLE", + "name": "MINTER_ROLE", "outputs": [ { "internalType": "bytes32", @@ -311,12 +225,12 @@ "inputs": [ { "internalType": "address", - "name": "owner", + "name": "", "type": "address" }, { "internalType": "address", - "name": "spender", + "name": "", "type": "address" } ], @@ -328,19 +242,19 @@ "type": "uint256" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { "inputs": [ { "internalType": "address", - "name": "spender", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -352,7 +266,7 @@ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" }, { @@ -376,6 +290,11 @@ }, { "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, { "internalType": "uint256", "name": "amount", @@ -509,7 +428,7 @@ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { @@ -577,7 +496,7 @@ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { @@ -597,12 +516,12 @@ "inputs": [ { "internalType": "address", - "name": "to", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -614,24 +533,24 @@ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" }, { "inputs": [ { "internalType": "address", - "name": "from", + "name": "", "type": "address" }, { "internalType": "address", - "name": "to", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -643,7 +562,7 @@ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" } ] diff --git a/contracts/abi/Points.ts b/contracts/abi/Points.ts index bc82508..601d65d 100644 --- a/contracts/abi/Points.ts +++ b/contracts/abi/Points.ts @@ -33,93 +33,7 @@ export const PointsAbi = [ }, { "inputs": [], - "name": "AlreadyFinalized", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "allowance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientAllowance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - }, - { - "internalType": "uint256", - "name": "balance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", - "type": "uint256" - } - ], - "name": "ERC20InsufficientBalance", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "approver", - "type": "address" - } - ], - "name": "ERC20InvalidApprover", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "receiver", - "type": "address" - } - ], - "name": "ERC20InvalidReceiver", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "ERC20InvalidSender", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "spender", - "type": "address" - } - ], - "name": "ERC20InvalidSpender", + "name": "InsufficientBalance", "type": "error" }, { @@ -129,7 +43,7 @@ export const PointsAbi = [ }, { "inputs": [], - "name": "TransferNotAllowed", + "name": "TransfersDisabled", "type": "error" }, { @@ -270,7 +184,7 @@ export const PointsAbi = [ }, { "inputs": [], - "name": "DEFAULT_ADMIN_ROLE", + "name": "BURNER_ROLE", "outputs": [ { "internalType": "bytes32", @@ -283,7 +197,7 @@ export const PointsAbi = [ }, { "inputs": [], - "name": "MINTER_ROLE", + "name": "DEFAULT_ADMIN_ROLE", "outputs": [ { "internalType": "bytes32", @@ -296,7 +210,7 @@ export const PointsAbi = [ }, { "inputs": [], - "name": "TRANSFER_ROLE", + "name": "MINTER_ROLE", "outputs": [ { "internalType": "bytes32", @@ -311,12 +225,12 @@ export const PointsAbi = [ "inputs": [ { "internalType": "address", - "name": "owner", + "name": "", "type": "address" }, { "internalType": "address", - "name": "spender", + "name": "", "type": "address" } ], @@ -328,19 +242,19 @@ export const PointsAbi = [ "type": "uint256" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { "inputs": [ { "internalType": "address", - "name": "spender", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -352,7 +266,7 @@ export const PointsAbi = [ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" }, { @@ -376,6 +290,11 @@ export const PointsAbi = [ }, { "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, { "internalType": "uint256", "name": "amount", @@ -509,7 +428,7 @@ export const PointsAbi = [ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { @@ -577,7 +496,7 @@ export const PointsAbi = [ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { @@ -597,12 +516,12 @@ export const PointsAbi = [ "inputs": [ { "internalType": "address", - "name": "to", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -614,24 +533,24 @@ export const PointsAbi = [ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" }, { "inputs": [ { "internalType": "address", - "name": "from", + "name": "", "type": "address" }, { "internalType": "address", - "name": "to", + "name": "", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "", "type": "uint256" } ], @@ -643,7 +562,7 @@ export const PointsAbi = [ "type": "bool" } ], - "stateMutability": "nonpayable", + "stateMutability": "pure", "type": "function" } ] as const; diff --git a/contracts/abi/PointsHook.json b/contracts/abi/PointsHook.json index 11ecfff..7c0e019 100644 --- a/contracts/abi/PointsHook.json +++ b/contracts/abi/PointsHook.json @@ -51,78 +51,11 @@ "name": "AccessControlUnauthorizedAccount", "type": "error" }, - { - "inputs": [], - "name": "InvalidReferrer", - "type": "error" - }, - { - "inputs": [], - "name": "ReferrerAlreadySet", - "type": "error" - }, { "inputs": [], "name": "ZeroAddress", "type": "error" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "accountCap", - "type": "uint256" - } - ], - "name": "AccountCapSet", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "account", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "bool", - "name": "isMaker", - "type": "bool" - } - ], - "name": "FillPointsMinted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "liquidator", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "KeeperPointsMinted", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -136,25 +69,6 @@ "name": "KeeperPointsSet", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "stepBps", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "maxBps", - "type": "uint256" - } - ], - "name": "LoyaltySet", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -168,63 +82,6 @@ "name": "MinFeeSet", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "referralBps", - "type": "uint256" - } - ], - "name": "ReferralBpsSet", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "referrer", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "referee", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "ReferralPointsMinted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "referee", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "referrer", - "type": "address" - } - ], - "name": "ReferrerSet", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -319,19 +176,6 @@ "name": "WeightsSet", "type": "event" }, - { - "inputs": [], - "name": "BPS", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "DEFAULT_ADMIN_ROLE", @@ -371,38 +215,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "accountCap", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "earned", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -477,51 +289,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "lastActivityDay", - "outputs": [ - { - "internalType": "uint64", - "name": "", - "type": "uint64" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "loyaltyMaxBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "loyaltyStepBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "minFee", @@ -599,38 +366,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "referralBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "referrerOf", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -667,19 +402,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_accountCap", - "type": "uint256" - } - ], - "name": "setAccountCap", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -693,24 +415,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_stepBps", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_maxBps", - "type": "uint256" - } - ], - "name": "setLoyalty", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -724,32 +428,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_referralBps", - "type": "uint256" - } - ], - "name": "setReferralBps", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "referrer", - "type": "address" - } - ], - "name": "setReferrer", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -768,25 +446,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "streakDays", - "outputs": [ - { - "internalType": "uint32", - "name": "", - "type": "uint32" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { diff --git a/contracts/abi/PointsHook.ts b/contracts/abi/PointsHook.ts index 02a05b9..9ceb3d5 100644 --- a/contracts/abi/PointsHook.ts +++ b/contracts/abi/PointsHook.ts @@ -51,78 +51,11 @@ export const PointsHookAbi = [ "name": "AccessControlUnauthorizedAccount", "type": "error" }, - { - "inputs": [], - "name": "InvalidReferrer", - "type": "error" - }, - { - "inputs": [], - "name": "ReferrerAlreadySet", - "type": "error" - }, { "inputs": [], "name": "ZeroAddress", "type": "error" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "accountCap", - "type": "uint256" - } - ], - "name": "AccountCapSet", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "account", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "bool", - "name": "isMaker", - "type": "bool" - } - ], - "name": "FillPointsMinted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "liquidator", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "KeeperPointsMinted", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -136,25 +69,6 @@ export const PointsHookAbi = [ "name": "KeeperPointsSet", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "stepBps", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "maxBps", - "type": "uint256" - } - ], - "name": "LoyaltySet", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -168,63 +82,6 @@ export const PointsHookAbi = [ "name": "MinFeeSet", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint256", - "name": "referralBps", - "type": "uint256" - } - ], - "name": "ReferralBpsSet", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "referrer", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "referee", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - } - ], - "name": "ReferralPointsMinted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "referee", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "referrer", - "type": "address" - } - ], - "name": "ReferrerSet", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -319,19 +176,6 @@ export const PointsHookAbi = [ "name": "WeightsSet", "type": "event" }, - { - "inputs": [], - "name": "BPS", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "DEFAULT_ADMIN_ROLE", @@ -371,38 +215,6 @@ export const PointsHookAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "accountCap", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "earned", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -477,51 +289,6 @@ export const PointsHookAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "lastActivityDay", - "outputs": [ - { - "internalType": "uint64", - "name": "", - "type": "uint64" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "loyaltyMaxBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "loyaltyStepBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "minFee", @@ -599,38 +366,6 @@ export const PointsHookAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "referralBps", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "referrerOf", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -667,19 +402,6 @@ export const PointsHookAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_accountCap", - "type": "uint256" - } - ], - "name": "setAccountCap", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -693,24 +415,6 @@ export const PointsHookAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_stepBps", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_maxBps", - "type": "uint256" - } - ], - "name": "setLoyalty", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -724,32 +428,6 @@ export const PointsHookAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_referralBps", - "type": "uint256" - } - ], - "name": "setReferralBps", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "referrer", - "type": "address" - } - ], - "name": "setReferrer", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -768,25 +446,6 @@ export const PointsHookAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "streakDays", - "outputs": [ - { - "internalType": "uint32", - "name": "", - "type": "uint32" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { diff --git a/contracts/contracts/PointsHook.sol b/contracts/contracts/PointsHook.sol index 861a3e3..34d776c 100644 --- a/contracts/contracts/PointsHook.sol +++ b/contracts/contracts/PointsHook.sol @@ -53,8 +53,6 @@ contract PointsHook is IPointsHook, AccessControl { event WeightsSet(uint256 wMaker, uint256 wTaker); event KeeperPointsSet(uint256 keeperPoints); event MinFeeSet(uint256 minFee); - event FillPointsMinted(address indexed account, uint256 amount, bool isMaker); - event KeeperPointsMinted(address indexed liquidator, uint256 amount); /// @param _points The POINTS token (this hook must be set as its `minter`). /// @param admin Receives `DEFAULT_ADMIN_ROLE` (parameter tuning + role grants). @@ -90,12 +88,12 @@ contract PointsHook is IPointsHook, AccessControl { // Self-match exclusion: a wallet trading with itself earns nothing. if (maker == taker) return; - // Taker side. + // Taker side. The mint emits POINTS `Transfer(0x0 -> taker)`, which the + // leaderboard subgraph indexes — no separate accrual event is needed. if (takerFee >= minFee) { uint256 amount = (notional * wTaker) / WEIGHT_SCALE; if (amount > 0) { points.mint(taker, amount); - emit FillPointsMinted(taker, amount, false); } } @@ -105,7 +103,6 @@ contract PointsHook is IPointsHook, AccessControl { uint256 amount = (notional * wMaker) / WEIGHT_SCALE; if (amount > 0) { points.mint(maker, amount); - emit FillPointsMinted(maker, amount, true); } } } @@ -119,7 +116,6 @@ contract PointsHook is IPointsHook, AccessControl { uint256 amount = keeperPoints; if (amount > 0) { points.mint(liquidator, amount); - emit KeeperPointsMinted(liquidator, amount); } } diff --git a/contracts/tests/pointsHook.test.ts b/contracts/tests/pointsHook.test.ts index 15f7c27..8c420c2 100644 --- a/contracts/tests/pointsHook.test.ts +++ b/contracts/tests/pointsHook.test.ts @@ -1,6 +1,5 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getAddress } from "viem"; import { network } from "hardhat"; import { KEEPER_POINTS, @@ -88,12 +87,7 @@ describe("PointsHook", () => { describe("onLiquidation", () => { it("mints flat keeper points", async () => { const { hook, points, venue, keeper } = await networkHelpers.loadFixture(deployHookFixture); - await viem.assertions.emitWithArgs( - hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account }), - hook, - "KeeperPointsMinted", - [getAddress(keeper.account.address), KEEPER_POINTS], - ); + await hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account }); assert.equal(await points.read.balanceOf([keeper.account.address]), KEEPER_POINTS); }); }); diff --git a/contracts/tests/pointsIntegrationFixtures.ts b/contracts/tests/pointsIntegrationFixtures.ts new file mode 100644 index 0000000..7940eb2 --- /dev/null +++ b/contracts/tests/pointsIntegrationFixtures.ts @@ -0,0 +1,129 @@ +import type { NetworkConnection } from "hardhat/types/network"; +import type { ArtifactMap } from "hardhat/types/artifacts"; +import { getContract } from "viem"; +import type { Abi, Address, GetContractReturnType, PublicClient, WalletClient } from "viem"; +import { KEEPER_POINTS, NOTIONAL, W_MAKER, W_TAKER } from "./pointsFixtures.js"; + +// Contract ABIs mapping from Hardhat's artifact map. +type ContractAbis = { + [K in keyof ArtifactMap]: ArtifactMap[K] extends { abi: infer A } ? A : never; +}; + +type ContractInstance = GetContractReturnType< + ContractAbis[ContractName], + { public: PublicClient; wallet: WalletClient }, + Address +>; + +/** + * Deploy a contract from its compiled Hardhat artifact JSON using raw viem. + * + * The points-indexer hardhat project has no Solidity sources of its own, so we + * cannot use `viem.deployContract(name)` (it resolves artifacts from the current + * project). Instead we read the artifact emitted by the contracts package and + * deploy its bytecode directly — the same approach the futures indexer uses. + */ +export async function deployContract( + walletClient: WalletClient, + publicClient: PublicClient, + artifactPath: string, + args: unknown[] = [], +): Promise> { + const { readFile } = await import("node:fs/promises"); + const content = await readFile(new URL(artifactPath, import.meta.url), "utf-8"); + const artifact = JSON.parse(content); + + const abi = artifact.abi as Abi; + const bytecode = (artifact.bytecode?.object ?? artifact.bytecode) as `0x${string}`; + + const { deployContract: viemDeploy } = await import("viem/actions"); + if (walletClient.account === undefined) { + throw new Error("Wallet client must have an account"); + } + const txHash = await viemDeploy(walletClient, { + abi, + bytecode, + args, + account: walletClient.account, + chain: walletClient.chain, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (!receipt.contractAddress) { + throw new Error("Contract deployment failed: no contract address in receipt"); + } + + return getContract({ + address: receipt.contractAddress, + abi, + client: { public: publicClient, wallet: walletClient, chain: walletClient.chain }, + }) as unknown as ContractInstance; +} + +const ARTIFACTS = { + points: "../artifacts/contracts/Points.sol/Points.json", + hook: "../artifacts/contracts/PointsHook.sol/PointsHook.json", + redeemer: "../artifacts/contracts/PointsRedeemer.sol/PointsRedeemer.json", + gov: "../artifacts/contracts/mocks/GovTokenMock.sol/GovTokenMock.json", + escrow: "../artifacts/contracts/mocks/VestingEscrowMock.sol/VestingEscrowMock.json", +} as const; + +export { KEEPER_POINTS, NOTIONAL, W_MAKER, W_TAKER }; + +/** Taker points for one `NOTIONAL` fill at `W_TAKER` (1000 POINTS, 6 decimals). */ +export const TAKER_PTS = (NOTIONAL * W_TAKER) / 10n ** 18n; +/** Maker points for one `NOTIONAL` fill at `W_MAKER` (1500 POINTS, 6 decimals). */ +export const MAKER_PTS = (NOTIONAL * W_MAKER) / 10n ** 18n; +/** A fee comfortably above any minimum threshold (1 unit, 6 decimals). */ +export const FEE = 1_000_000n; + +/** + * Full points stack on one chain, wired exactly as production deploys it: + * - `Points` (HP ledger), admin = owner, + * - `PointsHook` holds POINTS `MINTER_ROLE`; `venue` wallet holds `HOOK_CALLER_ROLE` + * (stands in for the perps / futures venue contract), + * - `PointsRedeemer` holds POINTS `BURNER_ROLE`, funded from a `GovTokenMock` pool and + * escrowing the locked half into a `VestingEscrowMock`. + * + * Returns the live viem contract handles plus their ABIs so the matchstick harness can + * `bind("Points", …)` / `bind("PointsRedeemer", …)` against the deployed addresses. + */ +export async function deployPointsStackFixture(conn: NetworkConnection) { + const { viem } = conn; + const [owner, alice, bob, carol, venue, keeper] = await viem.getWalletClients(); + const pc = await viem.getPublicClient(); + + const points = await deployContract<"Points">(owner, pc, ARTIFACTS.points, [ + owner.account.address, + ]); + const hook = await deployContract<"PointsHook">(owner, pc, ARTIFACTS.hook, [ + points.address, + owner.account.address, + W_MAKER, + W_TAKER, + KEEPER_POINTS, + ]); + const gov = await deployContract<"GovTokenMock">(owner, pc, ARTIFACTS.gov, []); + const escrow = await deployContract<"VestingEscrowMock">(owner, pc, ARTIFACTS.escrow, []); + const redeemer = await deployContract<"PointsRedeemer">(owner, pc, ARTIFACTS.redeemer, [ + points.address, + gov.address, + escrow.address, + owner.account.address, + ]); + + const MINTER_ROLE = await points.read.MINTER_ROLE(); + const BURNER_ROLE = await points.read.BURNER_ROLE(); + const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); + await points.write.grantRole([MINTER_ROLE, hook.address], { account: owner.account }); + await points.write.grantRole([BURNER_ROLE, redeemer.address], { account: owner.account }); + await hook.write.grantRole([HOOK_CALLER_ROLE, venue.account.address], { account: owner.account }); + + return { + contracts: { points, hook, gov, escrow, redeemer }, + accounts: { owner, alice, bob, carol, venue, keeper, pc }, + roles: { MINTER_ROLE, BURNER_ROLE, HOOK_CALLER_ROLE }, + }; +} + +export type PointsStackFixture = Awaited>; diff --git a/docs/points-system-design.md b/docs/points-system-design.md index 83773f1..258d18e 100644 --- a/docs/points-system-design.md +++ b/docs/points-system-design.md @@ -99,7 +99,7 @@ function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) { /* - **Non-upgradeable, plain deploy.** Holds `MINTER_ROLE` on `POINTS`. Contains all the points math and weight parameters. Not a fund-holding contract. - Implements `IPointsHook` with two entry points called by the venues: - - `onFill(maker, taker, notional, makerFee, takerFee)` — called by perps `_executeMatch` and futures lot creation. Skips entirely on a self-match (`maker == taker`); otherwise mints `notional * w_taker / WEIGHT_SCALE` to the taker when `takerFee >= minFee`, and `notional * w_maker / WEIGHT_SCALE` to the maker when `makerFee > 0 && makerFee >= minFee` (a maker rebate earns nothing). Emits `FillPointsMinted(account, amount, isMaker)` per side. Note: one perps `createOrder` can walk the book and match against N resting maker orders in a single transaction, producing N `onFill` calls — so minting is O(matched levels) per taker transaction. + - `onFill(maker, taker, notional, makerFee, takerFee)` — called by perps `_executeMatch` and futures lot creation. Skips entirely on a self-match (`maker == taker`); otherwise mints `notional * w_taker / WEIGHT_SCALE` to the taker when `takerFee >= minFee`, and `notional * w_maker / WEIGHT_SCALE` to the maker when `makerFee > 0 && makerFee >= minFee` (a maker rebate earns nothing). Each side mints via `points.mint`, which emits the POINTS `Transfer(0x0 -> account)` the leaderboard subgraph indexes; the hook emits no separate accrual event. Note: one perps `createOrder` can walk the book and match against N resting maker orders in a single transaction, producing N `onFill` calls — so minting is O(matched levels) per taker transaction. - `onLiquidation(liquidator, fee)` — called by perps `liquidatePosition` and futures `liquidatePosition` / `liquidateOrder`. Mints flat keeper points to the liquidator. - **Caller authorization**: the hook checks that the caller holds a `HOOK_CALLER_ROLE`, granted only to the two venue contracts, so arbitrary addresses cannot mint points by calling the hook directly. - **Retuning**: changing `w_maker`, `w_taker`, or the keeper rate is done by deploying a new `PointsHook` and calling `setHook()` on each venue. No proxy is required because the hook is designed to be **replaced**, not upgraded. @@ -122,19 +122,33 @@ interface IPointsHook { Venue changes are deliberately minimal and live in the venue repos, not here: -- Each venue stores a `hook` address with a `setHook(address)` owner setter. -- Each venue adds call sites that invoke the hook with failure isolation: +- Each venue stores an `IPointsHook hook` address (appended at the end of storage for upgrade + safety) with a `setHook(address)` owner setter that emits `HookUpdated`. +- Each venue adds call sites that invoke the hook directly, skipping when it is unset: ```solidity -if (hook != address(0)) { - try IPointsHook(hook).onFill(maker, taker, notional, makerFee, takerFee) {} - catch {} +if (address(hook) != address(0)) { + hook.onFill(maker, taker, notional, makerFee, takerFee); } ``` -- The `try/catch` is the **failure isolation**: a bug or revert in the points hook must never block a fill, a liquidation, or any trading-critical path, and must never let points logic DoS the matching engine. +- **No `try/catch` isolation.** An earlier draft wrapped the call so a points-side revert could + never block trading, but the call is intentionally *not* isolated. Rationale: + - The hook is a small, owner-controlled, non-upgradeable contract; if it ever misbehaves it is + unplugged instantly with `setHook(address(0))` — no upgrade, no migration. + - `try/catch` interacts badly with `eth_estimateGas`: because the catch swallows an + out-of-gas inner call, estimation settles on the gas level where the hook no-ops, so points + would silently fail to mint unless callers always added a gas buffer. + - Failing loudly surfaces misconfiguration (e.g. the venue missing `HOOK_CALLER_ROLE`, or the + POINTS token already `finalize()`d) instead of silently dropping points. +- **Operational consequence**: because a reverting hook *does* block fills and liquidations, the + hook MUST be unplugged (`setHook(address(0))` on every venue) BEFORE `Points.finalize()` — after + finalize, `mint` reverts and would otherwise brick trading. The venue (proxy) must also hold + `HOOK_CALLER_ROLE` on the hook before it is plugged in. - Setting `hook = address(0)` disables points entirely, with no contract upgrade. - The only thing the venue repos import from collateral-margin is the `IPointsHook` interface. + Each venue depends on collateral-margin via `package.json` and adds `Points.sol` / `PointsHook.sol` + to its Hardhat `npmFilesToBuild` so the real contracts (not mocks) are used in integration tests. ### 5.4 In-protocol anti-gaming @@ -163,18 +177,17 @@ If a later program replaces POINTS with real GOV, a migration contract similar t The `Points` balance is the **canonical** ledger. The indexer is not the source of truth; it serves two purposes: 1. **Live leaderboard** — the primary user-facing surface. A `UserPoints` entity, queryable by any frontend with `orderBy: total`. -2. **Mirror** — keeps the leaderboard in sync with on-chain events, so it always reflects canonical balances, with a per-category breakdown (maker / taker / keeper) derived from the hook's own events. +2. **Mirror** — keeps the leaderboard in sync with on-chain events, so it always reflects canonical balances. Every mint is also counted (`mintCount`) and recorded as a `PointsMint`. Design — the subgraph indexes **the points contracts only**, not the venue contracts: -- **Three data sources**, all in collateral-margin and deployable on a single chain: - - `Points` — `Transfer` (mint/burn → `total`, `totalSupply`) and `Finalized` (program lifecycle). - - `PointsHook` — `FillPointsMinted(account, amount, isMaker)` and `KeeperPointsMinted(liquidator, amount)`. These carry the **category breakdown** (maker / taker / keeper) directly, so the subgraph never re-derives the points formula. +- **Two data sources**, both in collateral-margin and deployable on a single chain: + - `Points` — `Transfer` (mint/burn → `total`, `totalSupply`, `mintCount`, `PointsMint`) and `Finalized` (program lifecycle). - `PointsRedeemer` — `RedemptionEnabled` and `Swapped` (burn + GOV payout split), feeding `PointsRedemption` entities. -- **Why hook events, not venue events**: the hook is the single source of truth for the formula, and it emits exactly the amount it minted. Indexing the hook avoids re-implementing maker/taker math in AssemblyScript and avoids drifting from the contract when weights change. It also removes the cross-network problem — there is one hook regardless of how many venues call it. +- **Why no hook data source**: every accrual ends in `points.mint(...)`, which emits a POINTS `Transfer(0x0 -> account)`. The subgraph mirrors that one stream — counting each mint and recording a `PointsMint` row — so the hook needs no dedicated accrual events and is not indexed. This avoids re-implementing maker/taker math in AssemblyScript and avoids drifting from the contract when weights change, since the subgraph never re-derives the formula. It also removes the cross-network problem — there is one POINTS token regardless of how many venues mint through it. The cost is that the maker/taker/keeper category split is no longer surfaced on-chain; it was dropped as non-essential analytics (see [`points-system-improvements.md`](./points-system-improvements.md) if it is ever needed). - **Dedicated subgraph**, not an extension of the production accounting subgraph. The points formula is volatile (it changes when the hook is redeployed); keeping it separate lets it re-sync independently of the accounting subgraph that keepers and the market maker depend on. -- **Mirror exactness**: `total` is reconciled from `Points.Transfer`, while the maker/taker/keeper split comes from the hook events — the two are asserted to agree in the subgraph tests. -- Entities: `PointsProgram` (totals + finalized flag), `UserPoints` (`total`, `makerPoints`, `takerPoints`, `keeperPoints`), `PointsMint`, `PointsCategory`, `PointsRedemption`. +- **Mirror exactness**: `total` / `totalSupply` and `mintCount` are reconciled directly from `Points.Transfer` — asserted in the subgraph tests. +- Entities: `PointsProgram` (totals, `mintCount`, finalized flag), `UserPoints` (`total`, `totalEarned`, `mintCount`), `PointsMint`, `PointsRedemption`. ## 8. Accepted tradeoffs @@ -196,12 +209,12 @@ Everything incentives-related lives in **collateral-margin**, the shared infrast - **Design document**: `collateral-margin/docs/points-system-design.md` (this file); deferred features in `collateral-margin/docs/points-system-improvements.md`. - **Contracts** (`Points`, `PointsHook`, `PointsRedeemer`, plus `GovTokenMock` / `VestingEscrowMock` for tests): `collateral-margin/contracts/contracts/`, with tests in `collateral-margin/contracts/tests/` and a `deploy-points.ts` script (`pnpm deploy:points`). - **Points subgraph** (leaderboard + mirror): `collateral-margin/points-indexer/`, separate from the existing accounting subgraph. -- **Venue wiring** (the `hook` address + `setHook` setter + the two `try/catch` call sites + `HOOK_CALLER_ROLE` grant): in the venue repos `perps/` and `futures-marketplace/`. They import only the `IPointsHook` interface from collateral-margin. +- **Venue wiring** (the `hook` address + `setHook` setter + the `onFill` / `onLiquidation` call sites + `HOOK_CALLER_ROLE` grant): in the venue repos `perps/` and `futures-marketplace/`. They import only the `IPointsHook` interface from collateral-margin. - **GOV / `VestingEscrow`**: unchanged, in the `governance-token` repo. `PointsRedeemer` calls into the existing `VestingEscrow.lockFor`. ### Upstream coupling mitigations -Because the points subgraph indexes the **points contracts only** (`Points`, `PointsHook`, `PointsRedeemer`) and not the UUPS-upgradeable venue contracts, it does not depend on venue event signatures — a venue upgrade cannot silently break the leaderboard. The only coupling is the `IPointsHook` interface the venues import; that surface is small and pinned. Remaining hygiene: +Because the points subgraph indexes the **points contracts only** (`Points`, `PointsRedeemer`) and not the UUPS-upgradeable venue contracts, it does not depend on venue event signatures — a venue upgrade cannot silently break the leaderboard. The only coupling is the `IPointsHook` interface the venues import; that surface is small and pinned. Remaining hygiene: - Vendor pinned ABI files for the three points contracts into `points-indexer/abis/` (reusing the org's existing `contracts/abi` -> `keeper/src/abi.ts` copy convention). - Track an explicit start block per data source. @@ -210,7 +223,7 @@ Because the points subgraph indexes the **points contracts only** (`Points`, `Po ```mermaid flowchart TD - trade["Trade / liquidation on perps / futures"] -->|"try onFill() / onLiquidation()"| pointsHook["PointsHook contract"] + trade["Trade / liquidation on perps / futures"] -->|"onFill() / onLiquidation()"| pointsHook["PointsHook contract"] pointsHook -->|"Points.mint()"| points["Points ledger (non-transferable, HP)"] pointsHook --> mirror["Points subgraph -> leaderboard"] points --> mirror diff --git a/points-indexer/.env.example b/points-indexer/.env.example index 0b691ee..2ed9ece 100644 --- a/points-indexer/.env.example +++ b/points-indexer/.env.example @@ -1,14 +1,12 @@ # ── Subgraph manifest ────────────────────────────────────────────────────── -# Hard precondition (design §7): Points, PointsHook and PointsRedeemer must all -# be deployed on the SAME network. A single subgraph cannot index across networks. +# Hard precondition (design §7): Points and PointsRedeemer must be deployed on +# the SAME network. A single subgraph cannot index across networks. The hook is +# not indexed — accrual is mirrored from the POINTS Transfer (mint) stream. NETWORK=base-sepolia POINTS_ADDRESS=0x0000000000000000000000000000000000000000 POINTS_START_BLOCK=0 -HOOK_ADDRESS=0x0000000000000000000000000000000000000000 -HOOK_START_BLOCK=0 - REDEEMER_ADDRESS=0x0000000000000000000000000000000000000000 REDEEMER_START_BLOCK=0 diff --git a/points-indexer/.gitignore b/points-indexer/.gitignore index a7c8ec5..11c6847 100644 --- a/points-indexer/.gitignore +++ b/points-indexer/.gitignore @@ -9,3 +9,11 @@ node_modules # Matchstick test runtime artifacts tests/.bin/ tests/.latest.json + +# Auto-generated by subgraph-snapshot's runMatchstickTest() +tests/runner.test.ts +tests/.tmp/ + +# Hardhat output from integration tests (`pnpm test:integration`) +artifacts +cache diff --git a/points-indexer/README.md b/points-indexer/README.md index 5d7f172..649c487 100644 --- a/points-indexer/README.md +++ b/points-indexer/README.md @@ -7,23 +7,22 @@ changes. ## What it indexes -Three data sources, all from the points contracts in `../contracts`: +Two data sources, from the points contracts in `../contracts`: | Source | Events | Purpose | | --- | --- | --- | -| `Points` (HP token) | `Transfer`, `Finalized` | Canonical balance mirror: `UserPoints.total`, program `totalPoints`/`totalMinted`/`totalBurned`, `finalized`. | -| `PointsHook` | `FillPointsMinted`, `KeeperPointsMinted` | Per-category breakdown (maker / taker / keeper) + individual `PointsMint` rows. | +| `Points` (HP token) | `Transfer`, `Finalized` | Canonical balance mirror + mint ledger: `UserPoints.total`/`totalEarned`/`mintCount`, individual `PointsMint` rows, program `totalPoints`/`totalMinted`/`totalBurned`/`mintCount`, `finalized`. | | `PointsRedeemer` | `Swapped` | Redemption tracking: `redeemedPoints`, `govReceived`, `PointsRedemption` rows. | -### Why hook events instead of venue events +### Why the hook is not indexed The design sketch listed `HashPowerPerpsDEX` + `Futures` as data sources and proposed -copying their volume/fee/maker-taker math into the subgraph. We index the **hook's own -events** instead, because `PointsHook` already emits the exact minted amount and category -(`FillPointsMinted(account, amount, isMaker)`, `KeeperPointsMinted(liquidator, amount)`). -That removes the need to re-derive the points formula off-chain entirely, so there is **no -logic-drift risk** when weights change — the subgraph never has to be kept in sync with the -weight parameters. The canonical balance still comes straight from the HP `Transfer` stream. +copying their volume/fee/maker-taker math into the subgraph. We avoid that entirely: every +accrual ends in `points.mint(...)`, which emits a POINTS `Transfer(0x0 -> account)`. The +subgraph mirrors that single stream — counting each mint (`mintCount`), recording it as a +`PointsMint`, and tracking the balance. There is **no logic-drift risk** when weights change, +because the subgraph never re-derives the points formula; it only reflects the hook's `mint` +side-effects. The hook therefore needs no events and is not a data source. ## Leaderboard query @@ -32,14 +31,14 @@ weight parameters. The canonical balance still comes straight from the HP `Trans userPoints(first: 100, orderBy: total, orderDirection: desc) { address total - makerPoints - takerPoints - keeperPoints + totalEarned + mintCount redeemedPoints } pointsProgram(id: "0") { totalPoints totalUsers + mintCount finalized } } @@ -47,9 +46,9 @@ weight parameters. The canonical balance still comes straight from the HP `Trans ## Preconditions -- **Same-chain deployment (hard requirement).** `Points`, `PointsHook`, and - `PointsRedeemer` must be deployed on the same network with finalized addresses; a single - subgraph cannot index across networks. Set them in `.env`. +- **Same-chain deployment (hard requirement).** `Points` and `PointsRedeemer` must be + deployed on the same network with finalized addresses; a single subgraph cannot index + across networks. Set them in `.env`. - ABIs are read from `../contracts/abi/*.json`, generated by the contracts package (`pnpm --filter collateral-margin-contracts compile`). Pin them to the deployed implementation version. @@ -62,5 +61,15 @@ pnpm install pnpm prepare-local # envsubst → subgraph.yaml pnpm codegen pnpm build -pnpm test # matchstick +pnpm test # matchstick unit tests (tests/) +pnpm test:integration # end-to-end: real contracts → mappings (integration/) ``` + +### Integration tests + +`integration/` runs the real points contracts on a Hardhat EVM and feeds their +emitted events through the actual subgraph mappings via `hardhat-matchstick-ts`, +asserting the resulting entities. It mirrors the harness in +`futures-marketplace/indexer/integration`. The contracts package must be compiled +first (`pnpm --filter collateral-margin-contracts compile`) so the artifacts the +fixtures deploy from exist. `subgraph.yaml` must also be present (`pnpm prepare-local`). diff --git a/points-indexer/hardhat.config.ts b/points-indexer/hardhat.config.ts new file mode 100644 index 0000000..f81e188 --- /dev/null +++ b/points-indexer/hardhat.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "hardhat/config"; +import hardhatNetworkHelpers from "@nomicfoundation/hardhat-network-helpers"; +import hardhatNodeTestRunner from "@nomicfoundation/hardhat-node-test-runner"; +import hardhatViem from "@nomicfoundation/hardhat-viem"; +import hardhatMatchstick from "hardhat-matchstick-ts"; + +export default defineConfig({ + solidity: { + version: "0.8.28", + }, + plugins: [hardhatNetworkHelpers, hardhatViem, hardhatMatchstick, hardhatNodeTestRunner], + paths: { + tests: { + nodejs: "integration", + }, + }, + matchstick: { + subgraphYaml: "subgraph.yaml", + schemaPath: "schema.graphql", + }, + networks: { + default: { + type: "edr-simulated", + mining: { + auto: true, + }, + }, + }, +}); diff --git a/points-indexer/integration/points-mint.test.ts b/points-indexer/integration/points-mint.test.ts new file mode 100644 index 0000000..02d867f --- /dev/null +++ b/points-indexer/integration/points-mint.test.ts @@ -0,0 +1,170 @@ +/** + * Integration tests: POINTS accrual mirrored from the on-chain `Transfer` stream. + * + * The points-indexer does NOT index the hook — every accrual ends in + * `points.mint(...)`, which emits `Transfer(0x0 -> account)`. These tests drive + * the real `PointsHook` from a venue wallet and assert that the subgraph counts + * each mint (`mintCount`), records a `PointsMint`, and tracks balances/totals. + */ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { read, type EntityFields } from "matchstick-ts"; +import { + FEE, + KEEPER_POINTS, + MAKER_PTS, + NOTIONAL, + TAKER_PTS, + deployPointsStackFixture, +} from "../../contracts/tests/pointsIntegrationFixtures.ts"; + +const conn = await network.getOrCreate(); + +describe("onFill accrual: maker + taker mints mirrored to the leaderboard", () => { + after(() => conn.matchstick.reset()); + + it("credits balances/totalEarned, counts each mint, and records PointsMint rows", async () => { + const { contracts, accounts } = + await conn.networkHelpers.loadFixture(deployPointsStackFixture); + const { points, hook } = contracts; + const { alice, bob, venue } = accounts; + + conn.matchstick.bind("Points", points.address, points.abi); + await conn.matchstick.captureViewMocks(); + await conn.matchstick.anchor(); + + // alice = maker, bob = taker. Both fees above threshold → both sides mint. + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + account: venue.account, + }); + + const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; + const bobAddr = bob.account.address.toLowerCase() as `0x${string}`; + + const snap = await conn.matchstick.indexSnapshot([ + read("UserPoints", aliceAddr), + read("UserPoints", bobAddr), + read("PointsProgram", "0"), + ]); + + const aliceUser = snap.entity("UserPoints", aliceAddr); + assert.ok(aliceUser, "maker UserPoints row must exist"); + assert.equal(String(aliceUser.total), String(MAKER_PTS), "maker balance = NOTIONAL * wMaker"); + assert.equal(String(aliceUser.totalEarned), String(MAKER_PTS)); + assert.equal(String(aliceUser.mintCount), "1"); + + const bobUser = snap.entity("UserPoints", bobAddr); + assert.ok(bobUser, "taker UserPoints row must exist"); + assert.equal(String(bobUser.total), String(TAKER_PTS), "taker balance = NOTIONAL * wTaker"); + assert.equal(String(bobUser.totalEarned), String(TAKER_PTS)); + assert.equal(String(bobUser.mintCount), "1"); + + const program = snap.entity("PointsProgram", "0"); + assert.ok(program); + assert.equal(String(program.totalMinted), String(MAKER_PTS + TAKER_PTS)); + assert.equal(String(program.totalPoints), String(MAKER_PTS + TAKER_PTS)); + assert.equal(String(program.mintCount), "2", "two mints (maker + taker) in one fill"); + assert.equal(String(program.totalUsers), "2"); + assert.equal(String(program.totalBurned), "0"); + + // One PointsMint row per mint, attributed to the right account. + const mints = snap.saved("PointsMint"); + assert.equal(mints.length, 2, "one PointsMint per mint"); + const byUser = new Map(mints.map((m: EntityFields) => [String(m.user).toLowerCase(), m])); + assert.equal(String(byUser.get(aliceAddr)?.amount), String(MAKER_PTS)); + assert.equal(String(byUser.get(bobAddr)?.amount), String(TAKER_PTS)); + for (const m of mints) { + assert.ok( + String(m.id).startsWith("0x"), + "PointsMint.id is `tx hash ++ logIndex` (hex Bytes)", + ); + assert.ok(BigInt(String(m.blockNumber)) > 0n, "PointsMint.blockNumber is set"); + assert.ok(BigInt(String(m.timestamp)) > 0n, "PointsMint.timestamp is set"); + } + }); +}); + +describe("onFill accrual: a self-match contributes nothing to the leaderboard", () => { + after(() => conn.matchstick.reset()); + + it("self-match mints no Transfer; only the genuine taker fill is mirrored", async () => { + const { contracts, accounts } = + await conn.networkHelpers.loadFixture(deployPointsStackFixture); + const { points, hook } = contracts; + const { alice, bob, carol, venue } = accounts; + + conn.matchstick.bind("Points", points.address, points.abi); + await conn.matchstick.captureViewMocks(); + await conn.matchstick.anchor(); + + // A self-match by alice (maker == taker) mints nothing... + await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE], { + account: venue.account, + }); + // ...while a real fill (carol maker w/ 0 fee → no maker mint; bob takes) mints once. + await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL, 0n, FEE], { + account: venue.account, + }); + + const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; + const bobAddr = bob.account.address.toLowerCase() as `0x${string}`; + const snap = await conn.matchstick.indexSnapshot([ + read("UserPoints", aliceAddr), + read("UserPoints", bobAddr), + read("PointsProgram", "0"), + ]); + + assert.equal(snap.entity("UserPoints", aliceAddr), null, "no leaderboard row for a self-match"); + const bobUser = snap.entity("UserPoints", bobAddr); + assert.ok(bobUser, "the genuine taker is on the leaderboard"); + assert.equal(String(bobUser.total), String(TAKER_PTS)); + assert.equal(String(bobUser.mintCount), "1"); + + assert.equal(snap.saved("PointsMint").length, 1, "exactly one mint: the taker fill"); + assert.equal(snap.saved("UserPoints").length, 1, "only the taker, not the self-matcher"); + + const program = snap.entity("PointsProgram", "0"); + assert.ok(program); + assert.equal(String(program.mintCount), "1"); + assert.equal(String(program.totalMinted), String(TAKER_PTS)); + assert.equal(String(program.totalUsers), "1"); + }); +}); + +describe("onLiquidation accrual: flat keeper points mirrored", () => { + after(() => conn.matchstick.reset()); + + it("mints KEEPER_POINTS to the liquidator and counts it as a mint", async () => { + const { contracts, accounts } = + await conn.networkHelpers.loadFixture(deployPointsStackFixture); + const { points, hook } = contracts; + const { keeper, venue } = accounts; + + conn.matchstick.bind("Points", points.address, points.abi); + await conn.matchstick.captureViewMocks(); + await conn.matchstick.anchor(); + + await hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account }); + + const keeperAddr = keeper.account.address.toLowerCase() as `0x${string}`; + const snap = await conn.matchstick.indexSnapshot([ + read("UserPoints", keeperAddr), + read("PointsProgram", "0"), + ]); + + const keeperUser = snap.entity("UserPoints", keeperAddr); + assert.ok(keeperUser); + assert.equal(String(keeperUser.total), String(KEEPER_POINTS)); + assert.equal(String(keeperUser.totalEarned), String(KEEPER_POINTS)); + assert.equal(String(keeperUser.mintCount), "1"); + + const program = snap.entity("PointsProgram", "0"); + assert.ok(program); + assert.equal(String(program.totalMinted), String(KEEPER_POINTS)); + assert.equal(String(program.mintCount), "1"); + assert.equal(String(program.totalUsers), "1"); + + assert.equal(snap.saved("PointsMint").length, 1); + }); +}); diff --git a/points-indexer/integration/points-redemption.test.ts b/points-indexer/integration/points-redemption.test.ts new file mode 100644 index 0000000..cd37dc2 --- /dev/null +++ b/points-indexer/integration/points-redemption.test.ts @@ -0,0 +1,98 @@ +/** + * Integration tests: POINTS → GOV redemption. + * + * A swap burns the caller's POINTS (`Transfer(holder -> 0x0)`) and emits + * `PointsRedeemer.Swapped`. The subgraph must debit the balance + circulating + * supply from the burn, and record the GOV payout split from `Swapped` — while + * leaving `totalEarned` / `mintCount` untouched (a burn is not a mint). + */ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { read, type EntityFields } from "matchstick-ts"; +import { + FEE, + NOTIONAL, + TAKER_PTS, + deployPointsStackFixture, +} from "../../contracts/tests/pointsIntegrationFixtures.ts"; + +const conn = await network.getOrCreate(); + +/** alice earns 1000 POINTS, bob earns 3000 POINTS, pool = 4000 GOV. */ +const ALICE_PTS = TAKER_PTS; // 1000 POINTS (6 decimals) +const BOB_PTS = TAKER_PTS * 3n; // 3000 POINTS +const POOL = ALICE_PTS + BOB_PTS; // 4000 GOV, 1 GOV per POINT at this ratio + +describe("swap: burn debits supply, Swapped records the GOV payout split", () => { + after(() => conn.matchstick.reset()); + + it("debits balance + supply, records PointsRedemption, leaves totalEarned/mintCount intact", async () => { + const { contracts, accounts } = + await conn.networkHelpers.loadFixture(deployPointsStackFixture); + const { points, hook, gov, redeemer } = contracts; + const { owner, alice, bob, carol, venue } = accounts; + + conn.matchstick.bind("Points", points.address, points.abi); + conn.matchstick.bind("PointsRedeemer", redeemer.address, redeemer.abi); + await conn.matchstick.captureViewMocks(); + await conn.matchstick.anchor(); + + // Accrue: carol is the maker (makerFee 0 → no maker mint); alice/bob take. + await hook.write.onFill([carol.account.address, alice.account.address, NOTIONAL, 0n, FEE], { + account: venue.account, + }); + await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE], { + account: venue.account, + }); + + // Wind down: finalize, fund the pool, open redemption, then alice swaps. + await points.write.finalize({ account: owner.account }); + await gov.write.transfer([redeemer.address, POOL], { account: owner.account }); + await redeemer.write.enableRedemption([POOL], { account: owner.account }); + await redeemer.write.swap({ account: alice.account }); + + const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; + const expectedGov = (POOL * ALICE_PTS) / (ALICE_PTS + BOB_PTS); // 1000 GOV + const liquid = expectedGov / 2n; + const escrow = expectedGov - liquid; + + const snap = await conn.matchstick.indexSnapshot([ + read("UserPoints", aliceAddr), + read("PointsProgram", "0"), + ]); + + const aliceUser = snap.entity("UserPoints", aliceAddr); + assert.ok(aliceUser); + assert.equal(String(aliceUser.total), "0", "full balance burned on swap"); + assert.equal(String(aliceUser.totalEarned), String(ALICE_PTS), "totalEarned unaffected by burn"); + assert.equal(String(aliceUser.mintCount), "1", "burn is not a mint"); + assert.equal(String(aliceUser.redeemedPoints), String(ALICE_PTS)); + assert.equal(String(aliceUser.govReceived), String(expectedGov)); + + const program = snap.entity("PointsProgram", "0"); + assert.ok(program); + assert.equal(String(program.totalMinted), String(ALICE_PTS + BOB_PTS), "mints are sticky"); + assert.equal(String(program.totalBurned), String(ALICE_PTS)); + assert.equal( + String(program.totalPoints), + String(BOB_PTS), + "circulating supply drops by the burned amount", + ); + assert.equal(String(program.mintCount), "2", "two fills, unchanged by the burn"); + assert.equal(String(program.totalRedeemedPoints), String(ALICE_PTS)); + assert.equal(String(program.totalGovDistributed), String(expectedGov)); + assert.equal(String(program.redemptionCount), "1"); + assert.equal(String(program.finalized), "true", "Finalized() flips the program flag"); + + const redemptions = snap.saved("PointsRedemption"); + assert.equal(redemptions.length, 1, "one PointsRedemption per swap"); + const r = redemptions[0] as EntityFields; + assert.equal(String(r.user).toLowerCase(), aliceAddr); + assert.equal(String(r.pointsBurned), String(ALICE_PTS)); + assert.equal(String(r.govAmount), String(expectedGov)); + assert.equal(String(r.liquidAmount), String(liquid)); + assert.equal(String(r.escrowAmount), String(escrow)); + assert.ok(String(r.id).startsWith("0x"), "PointsRedemption.id is `tx hash ++ logIndex`"); + }); +}); diff --git a/points-indexer/integration/tsconfig.json b/points-indexer/integration/tsconfig.json new file mode 100644 index 0000000..fc83c1d --- /dev/null +++ b/points-indexer/integration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../contracts/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["../hardhat.config.ts", "./**/*.ts", "../../contracts/tests/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/points-indexer/package.json b/points-indexer/package.json index b01adf8..1c08a55 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -7,6 +7,7 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", + "postinstall": "node scripts/patch-matchstick-ts.mjs", "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", "codegen": "graph codegen", @@ -17,6 +18,8 @@ "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 points", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", "test": "graph test", + "test:integration": "hardhat test nodejs", + "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground" }, @@ -25,10 +28,24 @@ }, "devDependencies": { "@graphprotocol/graph-cli": "^0.98.1", + "@nomicfoundation/hardhat-network-helpers": "^3.0.8", + "@nomicfoundation/hardhat-node-test-runner": "^3.0.8", + "@nomicfoundation/hardhat-viem": "^3.0.7", "@types/node": "^25.3.0", "assemblyscript": "^0.19.23", + "collateral-margin-contracts": "link:../contracts", + "hardhat": "^3.4.0", + "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#main&path:packages/hardhat-matchstick-ts", "matchstick-as": "0.6.0", - "typescript": "^5.9.3" + "matchstick-ts": "github:lsheva/matchstick-ts#main&path:packages/matchstick-ts", + "typescript": "^5.9.3", + "viem": "^2.42.1" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "matchstick-ts", + "hardhat-matchstick-ts" + ] }, "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" } diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml index a87fe68..46e8e14 100644 --- a/points-indexer/pnpm-lock.yaml +++ b/points-indexer/pnpm-lock.yaml @@ -15,21 +15,60 @@ importers: '@graphprotocol/graph-cli': specifier: ^0.98.1 version: 0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76) + '@nomicfoundation/hardhat-network-helpers': + specifier: ^3.0.8 + version: 3.0.10(hardhat@3.9.0) + '@nomicfoundation/hardhat-node-test-runner': + specifier: ^3.0.8 + version: 3.0.17(hardhat@3.9.0) + '@nomicfoundation/hardhat-viem': + specifier: ^3.0.7 + version: 3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@types/node': specifier: ^25.3.0 version: 25.9.2 assemblyscript: specifier: ^0.19.23 version: 0.19.23 + collateral-margin-contracts: + specifier: link:../contracts + version: link:../contracts + hardhat: + specifier: ^3.4.0 + version: 3.9.0 + hardhat-matchstick-ts: + specifier: github:lsheva/matchstick-ts#main&path:packages/hardhat-matchstick-ts + version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0) matchstick-as: specifier: 0.6.0 version: 0.6.0 + matchstick-ts: + specifier: github:lsheva/matchstick-ts#main&path:packages/matchstick-ts + version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#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 + viem: + specifier: ^2.42.1 + version: 2.52.2(typescript@5.9.3)(zod@3.25.76) packages: + '@actions/core@1.11.1': + resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==} + + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + + '@actions/http-client@2.2.3': + resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==} + + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -48,6 +87,166 @@ packages: resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} engines: {node: '>=6'} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} @@ -211,6 +410,10 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -241,9 +444,21 @@ packages: '@multiformats/multiaddr@13.0.3': resolution: {integrity: sha512-mEqqJ4r3a/uuFMTpRkU316wGNIDQNhuVWpm+ebKTQeYsfv9jXbPONWM6VVnj3KGUrwfsX7GZOyp4TFqEA2SPCw==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + '@noble/curves@1.8.2': + resolution: {integrity: sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.2.0': resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} engines: {node: '>= 20.19.0'} @@ -252,6 +467,14 @@ packages: resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} + '@noble/hashes@1.7.2': + resolution: {integrity: sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} @@ -268,6 +491,103 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nomicfoundation/edr-darwin-arm64@0.12.0': + resolution: {integrity: sha512-z/8jU2dgZjhY2iLtJ1DGi3t/N2xbmjgok9K3R0f7+UZxSSJ5LbXCFn5So33fVh47RzGzOqEB+Yk4SdyUq2odqw==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-darwin-x64@0.12.0': + resolution: {integrity: sha512-F9RrA60mEtxfKFiGB7QsydoUwzz4ckoustVMFegcIKmjRxRVb1qrRYiAc9oQiKMdJWIZKDYsOpHABJ9Um4U/+g==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-arm64-gnu@0.12.0': + resolution: {integrity: sha512-EtGRZbh1d4BF/1SIG5rVrKQ9R0nuNvPgCYiU5fCmY3bojAFOUf4m7I2ezIhim1vb1QBlXmFaoFNPZFIdMltBGA==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-arm64-musl@0.12.0': + resolution: {integrity: sha512-5gWtmKuVfftcO1OUbF/3KTPVSR6klC7RI9Z96G5lDO325jQhYqOG+hkvDPKtM+nbYf+A0veOndghqbUgAX+E4A==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-x64-gnu@0.12.0': + resolution: {integrity: sha512-0Ty0fov3/NFL0dIshNTGCi06sjVzsgB7k+n9LAoj+57OsqP9X4e3P5XwjlTSNuyYshv8JdYMHqY+1ZIZ8SHsyQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-x64-musl@0.12.0': + resolution: {integrity: sha512-e5du3t17vdB3tAY0kl3Ip1cu8CcwiaeJeUC4mMPmL91HDM//AQehqpyIP8upam4AeIl1H6FA3xIUIDA6VOZSxg==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-win32-x64-msvc@0.12.0': + resolution: {integrity: sha512-wD8YxhFdlY2IXb7uVrSF7VHartFKXEYeiE6ISJOV10Y7YOUE1IzfwB0QOdKtLUepwB+HXis+KRy4h22dWEbT0A==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr@0.12.0': + resolution: {integrity: sha512-dVfrB70L//W05s+s+/c6n52Fct+kVKoXYT+/CKL9ZsNdq/yLr5LaPNsvpVkQHP1JdAiOrubUzA/MwZIk4gRxAQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/hardhat-errors@3.0.15': + resolution: {integrity: sha512-h3r32RzpmWEcB2bz6aqZKlKOP8tzyvHLkPFleFFqwVvjO5AUfSoMUxm8OjaTuNgPF35mXPYRISh+kNCwmsKVOA==} + + '@nomicfoundation/hardhat-network-helpers@3.0.10': + resolution: {integrity: sha512-zSw6vHOQYP5FOgbM1pvOerO6/022HFQUfzGCAb+CFu5iDq2Skpc1BI0DASOKBTTklwuXMHzDmsCJFHF07c9nuA==} + peerDependencies: + hardhat: ^3.8.0 + + '@nomicfoundation/hardhat-node-test-reporter@3.1.0': + resolution: {integrity: sha512-mcVaJyqQnryVOv/fWmo0IofVfRYvcoLyzR+D2LiLLtxvmmMTYLYW5ktPk0XHuJ+UBK735aZTrMTDUR3S/x9Edw==} + + '@nomicfoundation/hardhat-node-test-runner@3.0.17': + resolution: {integrity: sha512-zn+5BJ00n9pCFIAKJDegf65P7AM8+tcgCsrtXkjVH7wA46jssGaKQ8VduTwoekfZn8DdmwPTL7MWXqwjSS/Lkg==} + peerDependencies: + hardhat: ^3.8.0 + + '@nomicfoundation/hardhat-utils@4.1.3': + resolution: {integrity: sha512-SYDKX6SCdzs/5mC/S1D+AjNSQJrhxevHTI24TIouIjO0Xs0dB0+S4cYCqHWOgvnHLshrcrJ7XGUjS7+apWa1Tw==} + + '@nomicfoundation/hardhat-vendored@3.0.4': + resolution: {integrity: sha512-RO8Otj1FvRvxJmXzkxh1vTwK/+cqSVPYLqY6RrWkmzHEEcxnAwAFsBYdW7xyTEyW/pVbSSNd2gs3aoGdGZaoNA==} + + '@nomicfoundation/hardhat-viem@3.0.9': + resolution: {integrity: sha512-GtQ7l55C70Jj80yrZGdW7Kah0vPmEje29G/xVfcxchVxRRFdj1XyFZFn+7e53b11qhHMWDB787pVN+I8YZae3w==} + peerDependencies: + hardhat: ^3.8.0 + viem: ^2.47.6 + + '@nomicfoundation/hardhat-zod-utils@3.0.5': + resolution: {integrity: sha512-A1G9Jcizf/vYcGMtqkf+st94zBPTDB+bXXlojOMu77gmBZYbywY0k7hdRM2B4uJY+8nM0oe0sNVGVkARITXdcw==} + peerDependencies: + zod: ^3.23.8 + + '@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.2': + resolution: {integrity: sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-darwin-x64@0.1.2': + resolution: {integrity: sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.2': + resolution: {integrity: sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.2': + resolution: {integrity: sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.2': + resolution: {integrity: sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.2': + resolution: {integrity: sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.2': + resolution: {integrity: sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==} + engines: {node: '>= 12'} + + '@nomicfoundation/solidity-analyzer@0.1.2': + resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==} + engines: {node: '>= 12'} + '@oclif/core@4.11.4': resolution: {integrity: sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==} engines: {node: '>=18.0.0'} @@ -309,12 +629,34 @@ packages: '@scure/base@1.1.9': resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@scure/bip32@1.4.0': resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@scure/bip39@1.3.0': resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@sentry/core@9.47.1': + resolution: {integrity: sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==} + engines: {node: '>=18'} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@streamparser/json-node@0.0.22': + resolution: {integrity: sha512-sJT2ptNRwqB1lIsQrQlCoWk5rF4tif9wDh+7yluAGijJamAhrHGYpFB/Zg3hJeceoZypi74ftXk8DHzwYpbZSg==} + + '@streamparser/json@0.0.22': + resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -355,9 +697,24 @@ packages: zod: optional: true + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abort-error@1.0.2: resolution: {integrity: sha512-lVgvB2NyPLqbXXhVmXcYFTC1x5K7CiVdPgdY7LGgFQWC8506oN01sPN3i9cl9ynuwF4iJ0TS9exnR7cZ9FuX4w==} + adm-zip@0.4.16: + resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==} + engines: {node: '>=0.3.0'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -382,6 +739,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansis@3.17.0: resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} engines: {node: '>=14'} @@ -508,6 +869,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -639,6 +1004,10 @@ packages: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + docker-compose@1.3.0: resolution: {integrity: sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==} engines: {node: '>= 6.0.0'} @@ -674,6 +1043,10 @@ packages: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} engines: {node: '>=8.6'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + err-code@3.0.1: resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} @@ -698,6 +1071,11 @@ packages: es6-promisify@5.0.0: resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -709,6 +1087,9 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -720,6 +1101,10 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -798,6 +1183,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -865,6 +1255,19 @@ 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/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts: + resolution: {path: packages/hardhat-matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302} + version: 0.4.0 + engines: {node: '>=22.6'} + peerDependencies: + '@nomicfoundation/hardhat-network-helpers': ^3 + '@nomicfoundation/hardhat-viem': ^3 + hardhat: ^3 + + hardhat@3.9.0: + resolution: {integrity: sha512-MjLHYgxCbqhpLfu6FBkHkhTu9igJmtwgILJwdw7XUZ3xjnXLdGeXtwhykPA16hjeRrmWZH4yv5Tv4maw0dJASw==} + hasBin: true + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1046,6 +1449,11 @@ packages: peerDependencies: ws: '*' + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + it-all@3.0.11: resolution: {integrity: sha512-Gvqj6MO4GMLnFdtE68HZRpGBskNC+9+GQ+JevTGNYLyhjUuPhjDLU3jN1LpBemXJDW1bRSkczqA/qGyKlPKrcQ==} @@ -1087,6 +1495,14 @@ packages: engines: {node: '>=8'} hasBin: true + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1100,6 +1516,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-stream-stringify@3.1.6: + resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} + engines: {node: '>=7.10.1'} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -1189,6 +1609,20 @@ 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/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/matchstick-ts: + resolution: {path: packages/matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302} + version: 0.4.0 + engines: {node: '>=22.6'} + hasBin: true + peerDependencies: + '@graphprotocol/graph-cli': '>=0.90' + '@graphprotocol/graph-ts': '>=0.37' + matchstick-as: '>=0.6' + viem: ^2 + peerDependenciesMeta: + viem: + optional: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1204,6 +1638,12 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micro-eth-signer@0.14.0: + resolution: {integrity: sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==} + + micro-packed@0.7.3: + resolution: {integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1284,6 +1724,14 @@ packages: resolution: {integrity: sha512-YUOZbamht5mfLxPmk4M35CD/5DuOkAacxlEUbStVXpBAt4fyhBf+vZHI/HRkI++QUp3sNoeA2Gw4C+hi4eGSig==} engines: {node: '>=8'} + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-defer@3.0.0: resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} engines: {node: '>=8'} @@ -1295,6 +1743,10 @@ packages: p-fifo@1.0.0: resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + p-queue@9.3.0: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} @@ -1380,6 +1832,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1402,6 +1858,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-native-fetch-api@3.0.0: resolution: {integrity: sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==} @@ -1424,6 +1883,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -1432,6 +1895,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -1544,6 +2010,10 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -1577,9 +2047,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -1617,6 +2096,14 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + undici@6.26.0: + resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + engines: {node: '>=18.17'} + undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -1642,6 +2129,14 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + viem@2.52.2: + resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + wabt@1.0.24: resolution: {integrity: sha512-8l7sIOd3i5GWfTWciPL0+ff/FK/deVK2Q6FN+MPz4vfUcD78i2M/49XJTwF6aml91uIiuXJEsLKWMB2cw/mtKg==} hasBin: true @@ -1715,6 +2210,30 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -1751,6 +2270,24 @@ packages: snapshots: + '@actions/core@1.11.1': + dependencies: + '@actions/exec': 1.1.1 + '@actions/http-client': 2.2.3 + + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + + '@actions/http-client@2.2.3': + dependencies: + tunnel: 0.0.6 + undici: 5.29.0 + + '@actions/io@1.1.3': {} + + '@adraffy/ens-normalize@1.11.1': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1770,6 +2307,86 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@fastify/busboy@2.1.1': {} + '@fastify/busboy@3.2.0': {} '@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5': @@ -1962,6 +2579,10 @@ snapshots: '@isaacs/cliui@9.0.0': {} + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + '@leichtgewicht/ip-codec@2.0.5': {} '@libp2p/crypto@5.1.19': @@ -2039,16 +2660,30 @@ snapshots: uint8-varint: 3.0.0 uint8arrays: 6.1.1 + '@noble/ciphers@1.3.0': {} + '@noble/curves@1.4.2': dependencies: '@noble/hashes': 1.4.0 + '@noble/curves@1.8.2': + dependencies: + '@noble/hashes': 1.7.2 + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/curves@2.2.0': dependencies: '@noble/hashes': 2.2.0 '@noble/hashes@1.4.0': {} + '@noble/hashes@1.7.2': {} + + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} '@nodelib/fs.scandir@2.1.5': @@ -2063,6 +2698,111 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@nomicfoundation/edr-darwin-arm64@0.12.0': {} + + '@nomicfoundation/edr-darwin-x64@0.12.0': {} + + '@nomicfoundation/edr-linux-arm64-gnu@0.12.0': {} + + '@nomicfoundation/edr-linux-arm64-musl@0.12.0': {} + + '@nomicfoundation/edr-linux-x64-gnu@0.12.0': {} + + '@nomicfoundation/edr-linux-x64-musl@0.12.0': {} + + '@nomicfoundation/edr-win32-x64-msvc@0.12.0': {} + + '@nomicfoundation/edr@0.12.0': + dependencies: + '@nomicfoundation/edr-darwin-arm64': 0.12.0 + '@nomicfoundation/edr-darwin-x64': 0.12.0 + '@nomicfoundation/edr-linux-arm64-gnu': 0.12.0 + '@nomicfoundation/edr-linux-arm64-musl': 0.12.0 + '@nomicfoundation/edr-linux-x64-gnu': 0.12.0 + '@nomicfoundation/edr-linux-x64-musl': 0.12.0 + '@nomicfoundation/edr-win32-x64-msvc': 0.12.0 + + '@nomicfoundation/hardhat-errors@3.0.15': + dependencies: + '@nomicfoundation/hardhat-utils': 4.1.3 + + '@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0)': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.15 + '@nomicfoundation/hardhat-utils': 4.1.3 + hardhat: 3.9.0 + + '@nomicfoundation/hardhat-node-test-reporter@3.1.0': + dependencies: + '@actions/core': 1.11.1 + jest-diff: 29.7.0 + + '@nomicfoundation/hardhat-node-test-runner@3.0.17(hardhat@3.9.0)': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.15 + '@nomicfoundation/hardhat-node-test-reporter': 3.1.0 + '@nomicfoundation/hardhat-utils': 4.1.3 + '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) + hardhat: 3.9.0 + tsx: 4.22.4 + zod: 3.25.76 + + '@nomicfoundation/hardhat-utils@4.1.3': + dependencies: + '@streamparser/json-node': 0.0.22 + env-paths: 2.2.1 + ethereum-cryptography: 2.2.1 + fast-equals: 5.4.0 + json-stream-stringify: 3.1.6 + rfdc: 1.4.1 + undici: 6.26.0 + + '@nomicfoundation/hardhat-vendored@3.0.4': {} + + '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.15 + '@nomicfoundation/hardhat-utils': 4.1.3 + hardhat: 3.9.0 + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) + + '@nomicfoundation/hardhat-zod-utils@3.0.5(zod@3.25.76)': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.15 + '@nomicfoundation/hardhat-utils': 4.1.3 + zod: 3.25.76 + + '@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-darwin-x64@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.2': + optional: true + + '@nomicfoundation/solidity-analyzer@0.1.2': + optionalDependencies: + '@nomicfoundation/solidity-analyzer-darwin-arm64': 0.1.2 + '@nomicfoundation/solidity-analyzer-darwin-x64': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-arm64-musl': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-x64-gnu': 0.1.2 + '@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.2 + '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.2 + '@oclif/core@4.11.4': dependencies: ansi-escapes: 4.3.2 @@ -2152,17 +2892,40 @@ snapshots: '@scure/base@1.1.9': {} + '@scure/base@1.2.6': {} + '@scure/bip32@1.4.0': dependencies: '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip39@1.3.0': dependencies: '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@sentry/core@9.47.1': {} + + '@sinclair/typebox@0.27.10': {} + + '@streamparser/json-node@0.0.22': + dependencies: + '@streamparser/json': 0.0.22 + + '@streamparser/json@0.0.22': {} + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.2 @@ -2206,8 +2969,15 @@ snapshots: optionalDependencies: zod: 3.25.76 + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + abort-error@1.0.2: {} + adm-zip@0.4.16: {} + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -2226,6 +2996,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansis@3.17.0: {} any-signal@4.2.0: {} @@ -2355,6 +3127,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chardet@2.1.1: {} chokidar@4.0.3: @@ -2499,6 +3276,8 @@ snapshots: delay@5.0.0: {} + diff-sequences@29.6.3: {} + docker-compose@1.3.0: dependencies: yaml: 2.8.1 @@ -2535,6 +3314,8 @@ snapshots: dependencies: ansi-colors: 4.1.3 + env-paths@2.2.1: {} + err-code@3.0.1: {} error-ex@1.3.4: @@ -2555,6 +3336,35 @@ snapshots: dependencies: es6-promise: 4.2.8 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -2566,6 +3376,8 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 + eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} execa@5.1.1: @@ -2582,6 +3394,8 @@ snapshots: eyes@0.1.8: {} + fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} fast-glob@3.3.3: @@ -2652,6 +3466,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} generator-function@2.0.1: {} @@ -2754,6 +3571,37 @@ snapshots: graphql@16.11.0: {} + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0): + dependencies: + '@nomicfoundation/hardhat-network-helpers': 3.0.10(hardhat@3.9.0) + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + hardhat: 3.9.0 + matchstick-ts: link:../matchstick-ts + + hardhat@3.9.0: + dependencies: + '@nomicfoundation/edr': 0.12.0 + '@nomicfoundation/hardhat-errors': 3.0.15 + '@nomicfoundation/hardhat-utils': 4.1.3 + '@nomicfoundation/hardhat-vendored': 3.0.4 + '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) + '@nomicfoundation/solidity-analyzer': 0.1.2 + '@sentry/core': 9.47.1 + adm-zip: 0.4.16 + chokidar: 4.0.3 + enquirer: 2.3.6 + ethereum-cryptography: 2.2.1 + micro-eth-signer: 0.14.0 + p-map: 7.0.4 + resolve.exports: 2.0.3 + semver: 7.8.2 + tsx: 4.22.4 + ws: 8.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -2907,6 +3755,10 @@ snapshots: dependencies: ws: 7.5.11 + isows@1.0.7(ws@8.20.1): + dependencies: + ws: 8.20.1 + it-all@3.0.11: {} it-first@3.0.11: {} @@ -2966,6 +3818,15 @@ snapshots: - bufferutil - utf-8-validate + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-get-type@29.6.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -2976,6 +3837,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-stream-stringify@3.1.6: {} + json-stringify-safe@5.0.1: {} jsonfile@6.2.1: @@ -3080,6 +3943,16 @@ snapshots: dependencies: wabt: 1.0.24 + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#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 + graphql: 16.11.0 + matchstick-as: 0.6.0 + yaml: 2.8.1 + optionalDependencies: + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) + math-intrinsics@1.1.0: {} merge-options@3.0.4: @@ -3090,6 +3963,16 @@ snapshots: merge2@1.4.1: {} + micro-eth-signer@0.14.0: + dependencies: + '@noble/curves': 1.8.2 + '@noble/hashes': 1.7.2 + micro-packed: 0.7.3 + + micro-packed@0.7.3: + dependencies: + '@scure/base': 1.2.6 + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3164,6 +4047,21 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + ox@0.14.29(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-defer@3.0.0: {} p-defer@4.0.1: {} @@ -3173,6 +4071,8 @@ snapshots: fast-fifo: 1.3.2 p-defer: 3.0.0 + p-map@7.0.4: {} + p-queue@9.3.0: dependencies: eventemitter3: 5.0.4 @@ -3235,6 +4135,12 @@ snapshots: prettier@3.6.2: {} + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + process-nextick-args@2.0.1: {} progress-events@1.1.0: {} @@ -3257,6 +4163,8 @@ snapshots: queue-microtask@1.2.3: {} + react-is@18.3.1: {} + react-native-fetch-api@3.0.0: dependencies: p-defer: 3.0.0 @@ -3285,6 +4193,8 @@ snapshots: resolve-from@4.0.0: {} + resolve.exports@2.0.3: {} + restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -3292,6 +4202,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rimraf@2.7.1: dependencies: glob: 7.2.3 @@ -3396,6 +4308,10 @@ snapshots: dependencies: has-flag: 3.0.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -3435,10 +4351,18 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + type-fest@0.21.3: {} typed-array-buffer@1.0.3: @@ -3482,6 +4406,12 @@ snapshots: undici-types@7.24.6: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@6.26.0: {} + undici@7.16.0: {} universalify@2.0.1: {} @@ -3502,6 +4432,23 @@ snapshots: uuid@8.3.2: {} + viem@2.52.2(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.20.1) + ox: 0.14.29(typescript@5.9.3)(zod@3.25.76) + ws: 8.20.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + wabt@1.0.24: {} wcwidth@1.0.1: @@ -3586,6 +4533,10 @@ snapshots: ws@7.5.11: {} + ws@8.20.1: {} + + ws@8.21.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/points-indexer/schema.graphql b/points-indexer/schema.graphql index d523e52..343036b 100644 --- a/points-indexer/schema.graphql +++ b/points-indexer/schema.graphql @@ -3,9 +3,10 @@ # The POINTS (HP) ERC20 is the canonical balance. This subgraph is NOT the source # of truth; it serves two purposes (design §7): # 1. Live leaderboard — query `UserPoints(orderBy: total, orderDirection: desc)`. -# 2. Mirror — `total` tracks the on-chain HP `Transfer` mints/burns exactly, while -# the per-category breakdown (maker / taker / keeper) is derived from the -# PointsHook events that accompany each mint. +# 2. Mirror — `total` / `totalSupply` track the on-chain HP `Transfer` mints/burns +# exactly. Every mint (`from == 0x0`) is also counted (`mintCount`) and recorded +# as a `PointsMint`. The accrual formula lives entirely in `PointsHook`; the +# subgraph only mirrors its `mint` side-effects, so it never re-derives points. # ── Program root entity (singleton, id = "0") ─────────────────────────────── type PointsProgram @entity(immutable: false) { @@ -14,7 +15,6 @@ type PointsProgram @entity(immutable: false) { # Contract identity pointsToken: Bytes! - hook: Bytes! redeemer: Bytes! # Canonical mirror (from HP Transfer events) @@ -23,19 +23,13 @@ type PointsProgram @entity(immutable: false) { totalBurned: BigInt! # cumulative HP burned via redemption finalized: Boolean! # true once HP.finalize() has frozen minting - # Category breakdown (from PointsHook events) - makerPoints: BigInt! - takerPoints: BigInt! - keeperPoints: BigInt! - # Redemption (from PointsRedeemer events) totalRedeemedPoints: BigInt! totalGovDistributed: BigInt! # Counters totalUsers: Int! - fillCount: Int! - liquidationCount: Int! + mintCount: Int! # number of mint events across all users redemptionCount: Int! lastUpdatedAt: BigInt! @@ -51,18 +45,12 @@ type UserPoints @entity(immutable: false) { "Cumulative HP ever minted to this account (does not decrease on redemption)." totalEarned: BigInt! - # Category breakdown of `totalEarned` - makerPoints: BigInt! - takerPoints: BigInt! - keeperPoints: BigInt! - # Redemption redeemedPoints: BigInt! govReceived: BigInt! # Counters - fillCount: Int! - liquidationCount: Int! + mintCount: Int! # number of mints credited to this account # Relations mints: [PointsMint!]! @derivedFrom(field: "user") @@ -72,19 +60,11 @@ type UserPoints @entity(immutable: false) { lastActivityAt: BigInt! } -# ── Category of a mint, from the PointsHook event that produced it ─────────── -enum PointsCategory { - MAKER - TAKER - KEEPER -} - -# ── Individual mint (PointsHook FillPointsMinted / KeeperPointsMinted) ─────── +# ── Individual mint (HP Transfer with from == 0x0) ────────────────────────── type PointsMint @entity(immutable: true) { id: Bytes! # tx hash + log index user: UserPoints! amount: BigInt! - category: PointsCategory! timestamp: BigInt! blockNumber: BigInt! diff --git a/points-indexer/scripts/patch-matchstick-ts.mjs b/points-indexer/scripts/patch-matchstick-ts.mjs new file mode 100644 index 0000000..cd15e99 --- /dev/null +++ b/points-indexer/scripts/patch-matchstick-ts.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Postinstall patch for matchstick-ts. + * + * Upstream (lsheva/matchstick-ts) doesn't yet handle Solidity tuple/struct + * event params end-to-end: + * - `event-capture.js#serializeParams` did `JSON.stringify(value)` on object + * fields, which throws when the struct contains a `bigint` (every uint256). + * - `assembly/index.ts#jsonValueToEthereumValue` only handled scalar JSON + * kinds, falling back to `Value.fromString(value.toString())` for arrays. + * The AS-generated event class then called `.toTuple()` on the wrapper + * and aborted with "Ethereum value is not a tuple". + * + * The points contracts only emit scalar event params today, but we keep the + * patch for parity with the futures indexer so struct events added later index + * correctly. Until upstream lands the fix, this script rewrites both sides of + * the matchstick-ts wire format to encode tuples as JSON arrays. + * + * Runs after `pnpm install`; idempotent. + */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname; +const PNPM_STORE = join(ROOT, "node_modules", ".pnpm"); + +if (!existsSync(PNPM_STORE)) { + process.exit(0); +} + +const matchstickDirs = readdirSync(PNPM_STORE).filter((d) => + d.startsWith("matchstick-ts@"), +); + +let patched = 0; +for (const dir of matchstickDirs) { + const base = join(PNPM_STORE, dir, "node_modules", "matchstick-ts"); + + // --- JS-side: dist/event-capture.js (already-built artifact) --- + const jsPath = join(base, "dist", "event-capture.js"); + if (existsSync(jsPath)) { + let src = readFileSync(jsPath, "utf8"); + if (!src.includes("encodeTupleOrArray")) { + src = src.replace( + / else if \(typeof value === "object" && value !== null\) \{\s*\n\s*result\.push\(\[key, JSON\.stringify\(value\)\]\);\s*\n\s*\}/, + ` else if (typeof value === "object" && value !== null) { + result.push([key, encodeTupleOrArray(value)]); + }`, + ); + src = src.replace( + /\/\*\* Internal — shared by \{@link EventCapture\} and the log-sync ingester\. \*\/\nexport function serializeParams\(args\) \{/, + `function encodeTupleOrArray(v) { + if (typeof v === "bigint") return v.toString(); + if (Array.isArray(v)) return v.map(encodeTupleOrArray); + if (v !== null && typeof v === "object") return Object.values(v).map(encodeTupleOrArray); + return v; +} +/** Internal — shared by {@link EventCapture} and the log-sync ingester. */ +export function serializeParams(args) {`, + ); + writeFileSync(jsPath, src); + patched++; + } + } + + // --- AS-side: assembly/index.ts (recompiled by matchstick on every run) --- + const asPath = join(base, "assembly", "index.ts"); + if (existsSync(asPath)) { + let src = readFileSync(asPath, "utf8"); + if (!src.includes("JSONValueKind.ARRAY")) { + src = src.replace( + / if \(value\.kind == JSONValueKind\.BOOL\) \{\s*\n\s*return ethereum\.Value\.fromBoolean\(value\.toBool\(\)\);\s*\n\s*\}\s*\n\s*\/\/ Arrays\/objects fall back to string representation\.\s*\n\s*return ethereum\.Value\.fromString\(value\.toString\(\)\);/, + ` if (value.kind == JSONValueKind.BOOL) { + return ethereum.Value.fromBoolean(value.toBool()); + } + if (value.kind == JSONValueKind.ARRAY) { + // Treated as a Solidity tuple (struct). The TS-side serializer encodes + // both tuples and dynamic arrays as JSON arrays — the AS-generated event + // class then calls .toTuple() on the wrapper to get the struct back. + const arr = value.toArray(); + const tuple = new ethereum.Tuple(); + for (let i = 0; i < arr.length; i++) { + tuple.push(jsonValueToEthereumValue(arr[i])); + } + return ethereum.Value.fromTuple(tuple); + } + return ethereum.Value.fromString(value.toString());`, + ); + writeFileSync(asPath, src); + patched++; + } + } +} + +if (patched > 0) { + console.log(`patched matchstick-ts: ${patched} file(s) (tuple/bigint event params)`); +} diff --git a/points-indexer/src/points.ts b/points-indexer/src/points.ts index c42cae3..fa5afc9 100644 --- a/points-indexer/src/points.ts +++ b/points-indexer/src/points.ts @@ -1,17 +1,11 @@ import { Address, BigInt, Bytes, dataSource } from "@graphprotocol/graph-ts"; import { Finalized, Transfer } from "../generated/Points/Points"; -import { FillPointsMinted, KeeperPointsMinted } from "../generated/PointsHook/PointsHook"; import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; import { PointsMint, PointsProgram, PointsRedemption, UserPoints } from "../generated/schema"; import { createEventId } from "./ids"; const ZERO_ADDRESS = Address.zero(); -// PointsCategory enum string values (must match schema.graphql). -const CATEGORY_MAKER = "MAKER"; -const CATEGORY_TAKER = "TAKER"; -const CATEGORY_KEEPER = "KEEPER"; - // ── Helpers ─────────────────────────────────────────────────────────────── function getOrCreateProgram(): PointsProgram { @@ -19,20 +13,15 @@ function getOrCreateProgram(): PointsProgram { if (!program) { program = new PointsProgram("0"); program.pointsToken = Bytes.empty(); - program.hook = Bytes.empty(); program.redeemer = Bytes.empty(); program.totalPoints = BigInt.zero(); program.totalMinted = BigInt.zero(); program.totalBurned = BigInt.zero(); program.finalized = false; - program.makerPoints = BigInt.zero(); - program.takerPoints = BigInt.zero(); - program.keeperPoints = BigInt.zero(); program.totalRedeemedPoints = BigInt.zero(); program.totalGovDistributed = BigInt.zero(); program.totalUsers = 0; - program.fillCount = 0; - program.liquidationCount = 0; + program.mintCount = 0; program.redemptionCount = 0; program.lastUpdatedAt = BigInt.zero(); } @@ -50,13 +39,9 @@ function getOrCreateUser(address: Address, timestamp: BigInt, program: PointsPro user.address = address; user.total = BigInt.zero(); user.totalEarned = BigInt.zero(); - user.makerPoints = BigInt.zero(); - user.takerPoints = BigInt.zero(); - user.keeperPoints = BigInt.zero(); user.redeemedPoints = BigInt.zero(); user.govReceived = BigInt.zero(); - user.fillCount = 0; - user.liquidationCount = 0; + user.mintCount = 0; user.firstSeenAt = timestamp; user.lastActivityAt = timestamp; program.totalUsers += 1; @@ -64,30 +49,12 @@ function getOrCreateUser(address: Address, timestamp: BigInt, program: PointsPro return user; } -function recordMint( - user: UserPoints, - amount: BigInt, - category: string, - transactionHash: Bytes, - logIndex: BigInt, - blockNumber: BigInt, - timestamp: BigInt, -): void { - const mint = new PointsMint(createEventId(transactionHash, logIndex)); - mint.user = user.id; - mint.amount = amount; - mint.category = category; - mint.timestamp = timestamp; - mint.blockNumber = blockNumber; - mint.transactionHash = transactionHash; - mint.save(); -} - // ── Points token: canonical balance mirror ────────────────────────────────── // // POINTS (HP) blocks user-to-user transfers, so Transfer events are only mints // (from == 0x0, attribution) and burns (to == 0x0, redemption). That makes the -// stream a lossless ledger for `total` / `totalSupply`. +// stream a lossless ledger for `total` / `totalSupply`, and lets us count and +// record every mint without needing a separate accrual event from the hook. export function handleTransfer(event: Transfer): void { const from = event.params.from; const to = event.params.to; @@ -102,12 +69,22 @@ export function handleTransfer(event: Transfer): void { // Mint (attribution). program.totalMinted = program.totalMinted.plus(amount); program.totalPoints = program.totalPoints.plus(amount); + program.mintCount += 1; const user = getOrCreateUser(to, event.block.timestamp, program); user.total = user.total.plus(amount); user.totalEarned = user.totalEarned.plus(amount); + user.mintCount += 1; user.lastActivityAt = event.block.timestamp; user.save(); + + const mint = new PointsMint(createEventId(event.transaction.hash, event.logIndex)); + mint.user = user.id; + mint.amount = amount; + mint.timestamp = event.block.timestamp; + mint.blockNumber = event.block.number; + mint.transactionHash = event.transaction.hash; + mint.save(); } else if (to.equals(ZERO_ADDRESS)) { // Burn (redemption). program.totalBurned = program.totalBurned.plus(amount); @@ -130,76 +107,6 @@ export function handleFinalized(event: Finalized): void { program.save(); } -// ── PointsHook: per-category breakdown ────────────────────────────────────── - -export function handleFillPointsMinted(event: FillPointsMinted): void { - const program = getOrCreateProgram(); - if (program.hook.equals(Bytes.empty())) { - program.hook = dataSource.address(); - } - - const amount = event.params.amount; - const user = getOrCreateUser(event.params.account, event.block.timestamp, program); - - let category: string; - if (event.params.isMaker) { - user.makerPoints = user.makerPoints.plus(amount); - program.makerPoints = program.makerPoints.plus(amount); - category = CATEGORY_MAKER; - } else { - user.takerPoints = user.takerPoints.plus(amount); - program.takerPoints = program.takerPoints.plus(amount); - category = CATEGORY_TAKER; - } - - user.fillCount += 1; - user.lastActivityAt = event.block.timestamp; - user.save(); - - program.fillCount += 1; - program.lastUpdatedAt = event.block.timestamp; - program.save(); - - recordMint( - user, - amount, - category, - event.transaction.hash, - event.logIndex, - event.block.number, - event.block.timestamp, - ); -} - -export function handleKeeperPointsMinted(event: KeeperPointsMinted): void { - const program = getOrCreateProgram(); - if (program.hook.equals(Bytes.empty())) { - program.hook = dataSource.address(); - } - - const amount = event.params.amount; - const user = getOrCreateUser(event.params.liquidator, event.block.timestamp, program); - user.keeperPoints = user.keeperPoints.plus(amount); - user.liquidationCount += 1; - user.lastActivityAt = event.block.timestamp; - user.save(); - - program.keeperPoints = program.keeperPoints.plus(amount); - program.liquidationCount += 1; - program.lastUpdatedAt = event.block.timestamp; - program.save(); - - recordMint( - user, - amount, - CATEGORY_KEEPER, - event.transaction.hash, - event.logIndex, - event.block.number, - event.block.timestamp, - ); -} - // ── PointsRedeemer: POINTS → GOV swaps ────────────────────────────────────── export function handleSwapped(event: Swapped): void { diff --git a/points-indexer/subgraph.template.yaml b/points-indexer/subgraph.template.yaml index 71d322d..8caa842 100644 --- a/points-indexer/subgraph.template.yaml +++ b/points-indexer/subgraph.template.yaml @@ -1,16 +1,17 @@ # Use subgraph.template.yaml to add changes to the subgraph.yaml file. # Variables are substituted via envsubst from environment variables. # -# Hard precondition (design §7): the POINTS token, PointsHook and PointsRedeemer -# must all be deployed on the SAME network with finalized addresses. A single -# subgraph cannot index across networks. +# Hard precondition (design §7): the POINTS token and PointsRedeemer must be +# deployed on the SAME network with finalized addresses. A single subgraph cannot +# index across networks. The PointsHook is intentionally NOT indexed — all accrual +# is mirrored from the POINTS `Transfer` (mint) stream. specVersion: 1.3.0 indexerHints: prune: auto schema: file: ./schema.graphql dataSources: - # ── Canonical mirror: HP balances from Transfer (mint/burn) ─────────────── + # ── Canonical mirror: HP balances + mints from Transfer (mint/burn) ─────── - kind: ethereum name: Points network: "${NETWORK}" @@ -25,6 +26,7 @@ dataSources: entities: - PointsProgram - UserPoints + - PointsMint abis: - name: Points file: ../contracts/abi/Points.json @@ -35,32 +37,6 @@ dataSources: handler: handleFinalized file: ./src/points.ts - # ── Category breakdown: maker / taker / keeper from the hook ─────────────── - - kind: ethereum - name: PointsHook - network: "${NETWORK}" - source: - address: "${HOOK_ADDRESS}" - startBlock: ${HOOK_START_BLOCK} - abi: PointsHook - mapping: - kind: ethereum/events - apiVersion: 0.0.9 - language: wasm/assemblyscript - entities: - - PointsProgram - - UserPoints - - PointsMint - abis: - - name: PointsHook - file: ../contracts/abi/PointsHook.json - eventHandlers: - - event: FillPointsMinted(indexed address,uint256,bool) - handler: handleFillPointsMinted - - event: KeeperPointsMinted(indexed address,uint256) - handler: handleKeeperPointsMinted - file: ./src/points.ts - # ── Redemption: POINTS → GOV swaps ──────────────────────────────────────── - kind: ethereum name: PointsRedeemer diff --git a/points-indexer/tests/helpers.ts b/points-indexer/tests/helpers.ts index 1356831..30d1e0e 100644 --- a/points-indexer/tests/helpers.ts +++ b/points-indexer/tests/helpers.ts @@ -19,7 +19,6 @@ export function userAddress(id: i32): Address { } export const POINTS_ADDRESS = userAddress(255); -export const HOOK_ADDRESS = userAddress(254); export const REDEEMER_ADDRESS = userAddress(253); /** @@ -40,10 +39,6 @@ export function paramUint(name: string, value: BigInt): ethereum.EventParam { return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(value)); } -export function paramBool(name: string, value: boolean): ethereum.EventParam { - return new ethereum.EventParam(name, ethereum.Value.fromBoolean(value)); -} - export function paramBytes(name: string, value: Bytes): ethereum.EventParam { return new ethereum.EventParam(name, ethereum.Value.fromBytes(value)); } diff --git a/points-indexer/tests/points.test.ts b/points-indexer/tests/points.test.ts index e6a2377..103e14e 100644 --- a/points-indexer/tests/points.test.ts +++ b/points-indexer/tests/points.test.ts @@ -2,22 +2,13 @@ import { Address, BigInt } from "@graphprotocol/graph-ts"; import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; import { Finalized, Transfer } from "../generated/Points/Points"; -import { FillPointsMinted, KeeperPointsMinted } from "../generated/PointsHook/PointsHook"; import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; +import { handleFinalized, handleSwapped, handleTransfer } from "../src/points"; import { - handleFillPointsMinted, - handleFinalized, - handleKeeperPointsMinted, - handleSwapped, - handleTransfer, -} from "../src/points"; -import { - HOOK_ADDRESS, POINTS_ADDRESS, REDEEMER_ADDRESS, mockDataSource, paramAddr, - paramBool, paramUint, userAddress, } from "./helpers"; @@ -32,21 +23,6 @@ function transferEvent(from: Address, to: Address, value: BigInt): Transfer { ]); } -function fillEvent(account: Address, amount: BigInt, isMaker: boolean): FillPointsMinted { - return newTypedMockEventWithParams([ - paramAddr("account", account), - paramUint("amount", amount), - paramBool("isMaker", isMaker), - ]); -} - -function keeperEvent(liquidator: Address, amount: BigInt): KeeperPointsMinted { - return newTypedMockEventWithParams([ - paramAddr("liquidator", liquidator), - paramUint("amount", amount), - ]); -} - function swappedEvent( user: Address, pointsBurned: BigInt, @@ -69,27 +45,47 @@ describe("Points mirror (Transfer)", () => { mockDataSource(POINTS_ADDRESS); }); - test("mint credits balance, totalEarned, and program totals", () => { + test("mint credits balance, totalEarned, counts the mint, and records it", () => { const alice = userAddress(1); - handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + const evt = transferEvent(ZERO, alice, BigInt.fromI32(1_000_000)); + handleTransfer(evt); assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1000000"); assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1000000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "mintCount", "1"); assert.fieldEquals("PointsProgram", "0", "totalPoints", "1000000"); assert.fieldEquals("PointsProgram", "0", "totalMinted", "1000000"); assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); + assert.fieldEquals("PointsProgram", "0", "mintCount", "1"); + + const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); + assert.fieldEquals("PointsMint", id, "amount", "1000000"); + assert.fieldEquals("PointsMint", id, "user", alice.toHexString()); + }); + + test("repeated mints accumulate mintCount per user and program", () => { + const alice = userAddress(1); + handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); + handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(500_000))); + + assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1500000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "mintCount", "2"); + assert.fieldEquals("PointsProgram", "0", "mintCount", "2"); + assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); }); - test("burn debits balance and shrinks circulating supply", () => { + test("burn debits balance, shrinks supply, and does not count as a mint", () => { const alice = userAddress(1); handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_000_000))); handleTransfer(transferEvent(alice, ZERO, BigInt.fromI32(400_000))); assert.fieldEquals("UserPoints", alice.toHexString(), "total", "600000"); - // totalEarned does not decrease on burn. + // totalEarned and mintCount do not change on burn. assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1000000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "mintCount", "1"); assert.fieldEquals("PointsProgram", "0", "totalPoints", "600000"); assert.fieldEquals("PointsProgram", "0", "totalBurned", "400000"); + assert.fieldEquals("PointsProgram", "0", "mintCount", "1"); }); test("finalize flips the program flag", () => { @@ -98,50 +94,6 @@ describe("Points mirror (Transfer)", () => { }); }); -describe("Category breakdown (PointsHook)", () => { - beforeEach(() => { - clearStore(); - mockDataSource(HOOK_ADDRESS); - }); - - test("maker fill credits makerPoints and records a MAKER mint", () => { - const alice = userAddress(1); - const evt = fillEvent(alice, BigInt.fromI32(1_500_000), true); - handleFillPointsMinted(evt); - - assert.fieldEquals("UserPoints", alice.toHexString(), "makerPoints", "1500000"); - assert.fieldEquals("UserPoints", alice.toHexString(), "takerPoints", "0"); - assert.fieldEquals("UserPoints", alice.toHexString(), "fillCount", "1"); - assert.fieldEquals("PointsProgram", "0", "makerPoints", "1500000"); - assert.fieldEquals("PointsProgram", "0", "fillCount", "1"); - - const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); - assert.fieldEquals("PointsMint", id, "category", "MAKER"); - assert.fieldEquals("PointsMint", id, "amount", "1500000"); - }); - - test("taker fill credits takerPoints", () => { - const bob = userAddress(2); - handleFillPointsMinted(fillEvent(bob, BigInt.fromI32(1_000_000), false)); - assert.fieldEquals("UserPoints", bob.toHexString(), "takerPoints", "1000000"); - assert.fieldEquals("PointsProgram", "0", "takerPoints", "1000000"); - }); - - test("liquidation credits keeperPoints and records a KEEPER mint", () => { - const keeper = userAddress(3); - const evt = keeperEvent(keeper, BigInt.fromI32(5_000_000)); - handleKeeperPointsMinted(evt); - - assert.fieldEquals("UserPoints", keeper.toHexString(), "keeperPoints", "5000000"); - assert.fieldEquals("UserPoints", keeper.toHexString(), "liquidationCount", "1"); - assert.fieldEquals("PointsProgram", "0", "keeperPoints", "5000000"); - assert.fieldEquals("PointsProgram", "0", "liquidationCount", "1"); - - const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); - assert.fieldEquals("PointsMint", id, "category", "KEEPER"); - }); -}); - describe("Redemption (PointsRedeemer)", () => { beforeEach(() => { clearStore(); @@ -176,21 +128,29 @@ describe("End-to-end reconciliation", () => { clearStore(); }); - test("mirror total and category breakdown reconcile for one fill", () => { + test("mint then redeem reconciles balance, earned, and circulating supply", () => { const alice = userAddress(1); - // Token mint (canonical balance) ... mockDataSource(POINTS_ADDRESS); handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_500_000))); + // Redemption burns part of the balance via the token's Transfer(to == 0x0). + handleTransfer(transferEvent(alice, ZERO, BigInt.fromI32(500_000))); - // ... accompanied by the hook's maker attribution in the same logical tx. - mockDataSource(HOOK_ADDRESS); - handleFillPointsMinted(fillEvent(alice, BigInt.fromI32(1_500_000), true)); + mockDataSource(REDEEMER_ADDRESS); + handleSwapped( + swappedEvent( + alice, + BigInt.fromI32(500_000), + BigInt.fromI32(1_000_000), + BigInt.fromI32(500_000), + BigInt.fromI32(500_000), + ), + ); - assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1500000"); + assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1000000"); assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1500000"); - assert.fieldEquals("UserPoints", alice.toHexString(), "makerPoints", "1500000"); - // Only one distinct user across both data sources. + assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "500000"); + assert.fieldEquals("PointsProgram", "0", "totalPoints", "1000000"); assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); }); }); From 26a8a4c80d81e587c93d111ec5ca97a6e3f9dc0c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 9 Jun 2026 15:56:26 +0200 Subject: [PATCH 061/180] chore(scripts): use PERPS_ADDRESS env var in deploy scripts Align deploy-points and deploy-portfolio-margin-engine with the repo-wide PERPS_ADDRESS convention (keeper, market-maker, indexer, CI). Also gitignore the deploy output points-addr.tmp. --- contracts/.env.example | 2 +- contracts/.gitignore | 3 +++ contracts/scripts/deploy-points.ts | 6 +++--- contracts/scripts/deploy-portfolio-margin-engine.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/contracts/.env.example b/contracts/.env.example index a3073bc..123e6d2 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -34,7 +34,7 @@ PME_ADDRESS= # on the vault. Leave unset to skip a leg. Vault wiring runs only if the # deployer is still the vault owner; otherwise the script prints the calldata # that the current vault owner (typically a Safe) must execute. -PERPS_DEX_ADDRESS= +PERPS_ADDRESS= OPTIONS_ENGINE_ADDRESS= FUTURES_ADDRESS= diff --git a/contracts/.gitignore b/contracts/.gitignore index 538f239..a838946 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -3,3 +3,6 @@ artifacts cache .env *.tsbuildinfo + +# Deploy script output (deployed addresses) +points-addr.tmp diff --git a/contracts/scripts/deploy-points.ts b/contracts/scripts/deploy-points.ts index 9fb36c3..2aa320b 100644 --- a/contracts/scripts/deploy-points.ts +++ b/contracts/scripts/deploy-points.ts @@ -23,7 +23,7 @@ async function main() { const keeperPoints = readOptionalBigInt("POINTS_KEEPER") ?? DEFAULT_KEEPER_POINTS; const minFee = readOptionalBigInt("POINTS_MIN_FEE"); - const PERPS_DEX_ADDRESS = readOptionalAddress("PERPS_DEX_ADDRESS"); + const PERPS_ADDRESS = readOptionalAddress("PERPS_ADDRESS"); const FUTURES_ADDRESS = readOptionalAddress("FUTURES_ADDRESS"); const GOV_TOKEN_ADDRESS = readOptionalAddress("GOV_TOKEN_ADDRESS"); const VESTING_ESCROW_ADDRESS = readOptionalAddress("VESTING_ESCROW_ADDRESS"); @@ -41,7 +41,7 @@ async function main() { minFee: minFee?.toString() ?? "(0)", }); logInfo("venues (granted HOOK_CALLER_ROLE if set)", { - Perps: PERPS_DEX_ADDRESS ?? "(none)", + Perps: PERPS_ADDRESS ?? "(none)", Futures: FUTURES_ADDRESS ?? "(none)", }); logInfo("redeemer (deployed if both set)", { @@ -86,7 +86,7 @@ async function main() { // ── 5. Grant HOOK_CALLER_ROLE to the venues ───────────────────────────────── const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); for (const [label, addr] of [ - ["perps", PERPS_DEX_ADDRESS], + ["perps", PERPS_ADDRESS], ["futures", FUTURES_ADDRESS], ] as const) { if (!addr) continue; diff --git a/contracts/scripts/deploy-portfolio-margin-engine.ts b/contracts/scripts/deploy-portfolio-margin-engine.ts index df6c2c3..3cc6224 100644 --- a/contracts/scripts/deploy-portfolio-margin-engine.ts +++ b/contracts/scripts/deploy-portfolio-margin-engine.ts @@ -14,7 +14,7 @@ async function main() { const vaultAddress = requireAddress("VAULT_ADDRESS"); const SAFE_OWNER_ADDRESS = readOptionalAddress("SAFE_OWNER_ADDRESS"); - const PERPS_DEX_ADDRESS = readOptionalAddress("PERPS_DEX_ADDRESS"); + const PERPS_ADDRESS = readOptionalAddress("PERPS_ADDRESS"); const OPTIONS_ENGINE_ADDRESS = readOptionalAddress("OPTIONS_ENGINE_ADDRESS"); const FUTURES_ADDRESS = readOptionalAddress("FUTURES_ADDRESS"); @@ -44,7 +44,7 @@ async function main() { }); logInfo("optional engines (will be registered if set)", { - Perps: PERPS_DEX_ADDRESS ?? "(none)", + Perps: PERPS_ADDRESS ?? "(none)", Options: OPTIONS_ENGINE_ADDRESS ?? "(none)", Futures: FUTURES_ADDRESS ?? "(none)", }); @@ -126,10 +126,10 @@ async function main() { } // ── 4. Register product engines on PME (optional) ─────────────────────── - if (PERPS_DEX_ADDRESS) { - logInfo("PME.setPerps", { perpsDex: PERPS_DEX_ADDRESS }); + if (PERPS_ADDRESS) { + logInfo("PME.setPerps", { perpsDex: PERPS_ADDRESS }); await logPrompt("Proceed?"); - const sim = await pme.simulate.setPerps([PERPS_DEX_ADDRESS]); + const sim = await pme.simulate.setPerps([PERPS_ADDRESS]); const receipt = await writeAndWait(deployer, sim); logStep("Done", txUrl(pc, receipt.transactionHash)); } @@ -153,7 +153,7 @@ async function main() { // we surface the calldata that the current owner (typically a Safe) must // execute manually. const engines: { label: string; addr: Address }[] = []; - if (PERPS_DEX_ADDRESS) engines.push({ label: "perps", addr: PERPS_DEX_ADDRESS }); + if (PERPS_ADDRESS) engines.push({ label: "perps", addr: PERPS_ADDRESS }); if (OPTIONS_ENGINE_ADDRESS) engines.push({ label: "options", addr: OPTIONS_ENGINE_ADDRESS }); if (FUTURES_ADDRESS) engines.push({ label: "futures", addr: FUTURES_ADDRESS }); From c53468b818e4361c1dd2c40da8a3a6513ed1a926 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 9 Jun 2026 17:32:22 +0200 Subject: [PATCH 062/180] ci(points): add points subgraph deploy + test pipelines Add a Goldsky deploy workflow for points-indexer mirroring the collateral-vault subgraph pipeline, plus a CI workflow running the matchstick unit tests and the Hardhat-backed integration tests (compiling the contracts package for the integration fixtures). --- .github/workflows/deploy-points-subgraph.yml | 472 +++++++++++++++++++ .github/workflows/points-indexer-tests.yml | 98 ++++ 2 files changed, 570 insertions(+) create mode 100644 .github/workflows/deploy-points-subgraph.yml create mode 100644 .github/workflows/points-indexer-tests.yml diff --git a/.github/workflows/deploy-points-subgraph.yml b/.github/workflows/deploy-points-subgraph.yml new file mode 100644 index 0000000..992e60e --- /dev/null +++ b/.github/workflows/deploy-points-subgraph.yml @@ -0,0 +1,472 @@ +name: Deploy points subgraph + +on: + push: + branches: + - dev + - stg + - main + - "cicd/**" + paths: + - "points-indexer/**" + - "contracts/abi/Points.json" + - "contracts/abi/PointsRedeemer.json" + - ".github/workflows/deploy-points-subgraph.yml" + pull_request: + branches: + - dev + - stg + - main + paths: + - "points-indexer/**" + - "contracts/abi/Points.json" + - "contracts/abi/PointsRedeemer.json" + - ".github/workflows/deploy-points-subgraph.yml" + workflow_dispatch: + inputs: + environment: + description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - stg + - main + +concurrency: + group: ci-points-subgraph-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +permissions: + id-token: write # Required for OIDC + contents: write # Required for creating git tags + +env: + SERVICE_NAME: points-subgraph + +jobs: + setup: + name: 🔧 Setup + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.gen_tag.outputs.environment }} + is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} + version: ${{ steps.gen_tag.outputs.version }} + tag: ${{ steps.gen_tag.outputs.tag_name }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Generate version tag + id: gen_tag + uses: ./.github/actions/gen-tag + with: + component: points-indexer + major_version: "1" + environment_override: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || '' }} + + build: + name: 🔨 Build + runs-on: ubuntu-latest + needs: setup + environment: ${{ github.event_name != 'pull_request' && needs.setup.outputs.environment || '' }} + outputs: + version: ${{ needs.setup.outputs.version }} + tag: ${{ needs.setup.outputs.tag }} + environment: ${{ needs.setup.outputs.environment }} + goldsky_subgraph_name: ${{ steps.env.outputs.goldsky_subgraph_name }} + goldsky_rolling_tag: ${{ steps.env.outputs.goldsky_rolling_tag }} + goldsky_endpoint: ${{ steps.env.outputs.goldsky_endpoint }} + is_cicd_branch: ${{ needs.setup.outputs.is_cicd_branch }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: points-indexer/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: points-indexer/pnpm-lock.yaml + + - name: Set environment outputs + id: env + run: | + ENV="${{ needs.setup.outputs.environment }}" + + # Goldsky subgraph name — override via vars.GOLDSKY_POINTS_SUBGRAPH_NAME (default: points) + GS_NAME="${{ vars.GOLDSKY_POINTS_SUBGRAPH_NAME }}" + if [ -z "$GS_NAME" ]; then + GS_NAME="points" + fi + echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT + + # Rolling tag and public endpoint per environment + case $ENV in + dev) + echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.DEV_GS_POINTS }}" >> $GITHUB_OUTPUT + ;; + stg) + echo "goldsky_rolling_tag=stg-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.STG_GS_POINTS }}" >> $GITHUB_OUTPUT + ;; + main) + echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT + echo "goldsky_endpoint=${{ vars.LMN_GS_POINTS }}" >> $GITHUB_OUTPUT + ;; + esac + + echo "🎯 Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" + + - name: Install dependencies + working-directory: ./points-indexer + run: pnpm install --frozen-lockfile + + - name: Prepare subgraph configuration + working-directory: ./points-indexer + env: + NETWORK: ${{ vars.NETWORK }} + POINTS_ADDRESS: ${{ vars.POINTS_ADDRESS }} + POINTS_START_BLOCK: ${{ vars.POINTS_START_BLOCK }} + REDEEMER_ADDRESS: ${{ vars.REDEEMER_ADDRESS }} + REDEEMER_START_BLOCK: ${{ vars.REDEEMER_START_BLOCK }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "ℹ️ PR build — using .env.example dummy values" + set -a && source .env.example && set +a + fi + echo "⚙️ Preparing subgraph for ${{ needs.setup.outputs.environment }}..." + echo " Network: ${NETWORK}" + echo " Points: ${POINTS_ADDRESS} (block ${POINTS_START_BLOCK})" + echo " Redeemer: ${REDEEMER_ADDRESS} (block ${REDEEMER_START_BLOCK})" + pnpm prepare:env + echo "✅ Configuration ready" + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Generate code, build, test + working-directory: ./points-indexer + run: | + pnpm codegen + pnpm build + pnpm test + echo "✅ Build complete" + + - name: Upload build artifacts + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: points-subgraph-build + path: | + points-indexer/build/ + points-indexer/generated/ + points-indexer/src/ + points-indexer/subgraph.yaml + points-indexer/schema.graphql + contracts/abi/ + retention-days: 1 + + - name: Build summary + run: | + echo "## 🔨 Build Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${{ needs.setup.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Subgraph | \`${{ steps.env.outputs.goldsky_subgraph_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.env.outputs.goldsky_rolling_tag }}\` |" >> $GITHUB_STEP_SUMMARY + + - name: PR build summary + if: github.event_name == 'pull_request' + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** Build only (no deployment) — deploy will run on merge" >> $GITHUB_STEP_SUMMARY + + deploy: + name: 🚀 Deploy to Goldsky + runs-on: ubuntu-latest + needs: build + if: github.event_name != 'pull_request' + outputs: + deploy_status: ${{ steps.goldsky_deploy.outputs.deploy_status }} + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: points-subgraph-build + path: . + + - name: Install Goldsky CLI + run: curl -fsSL https://goldsky.com | sh -s -- -f + + - name: Deploy and tag + id: goldsky_deploy + working-directory: ./points-indexer + env: + GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || needs.build.outputs.environment == 'stg' && secrets.STG_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} + GOLDSKY_SUBGRAPH_NAME: ${{ needs.build.outputs.goldsky_subgraph_name }} + SUBGRAPH_VERSION: ${{ needs.build.outputs.version }} + GOLDSKY_ROLLING_TAG: ${{ needs.build.outputs.goldsky_rolling_tag }} + GOLDSKY_ENDPOINT: ${{ needs.build.outputs.goldsky_endpoint }} + run: | + echo "🚀 Goldsky Subgraph Deployment" + echo " Subgraph: ${GOLDSKY_SUBGRAPH_NAME}" + echo " Version: ${SUBGRAPH_VERSION}" + echo " Tag: ${GOLDSKY_ROLLING_TAG}" + echo "" + + # --- Pre-flight: check current deployment --- + if [ -n "${GOLDSKY_ENDPOINT}" ]; then + echo "📊 Current deployment:" + CURRENT=$(curl -s -X POST -H "Content-Type: application/json" \ + -d '{"query":"{ _meta { deployment hasIndexingErrors block { number } } }"}' \ + "${GOLDSKY_ENDPOINT}" 2>/dev/null || true) + echo " $(echo "$CURRENT" | jq -c '.data._meta // "unavailable"' 2>/dev/null || echo 'unavailable')" + echo "" + fi + + # --- Deploy new version --- + echo "📤 Deploying ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}..." + set +e + DEPLOY_OUTPUT=$(goldsky subgraph deploy "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --path . \ + --token "${GOLDSKY_API_KEY}" 2>&1) + DEPLOY_EXIT=$? + set -e + + echo "$DEPLOY_OUTPUT" + + SKIP_DEPLOY=false + if [ $DEPLOY_EXIT -ne 0 ]; then + if echo "$DEPLOY_OUTPUT" | grep -qi "already exists"; then + echo "" + echo "ℹ️ Version ${SUBGRAPH_VERSION} already exists — skipping to tag" + SKIP_DEPLOY=true + elif echo "$DEPLOY_OUTPUT" | grep -qi "already deployed"; then + CONFLICT=$(echo "$DEPLOY_OUTPUT" | grep -oP 'under the name \K\S+(?=\.)' || true) + echo "" + echo "⚠️ Duplicate content detected — conflicts with ${CONFLICT}" + echo " Removing conflicting version and retrying..." + goldsky subgraph tag delete "${CONFLICT}" --tag "${GOLDSKY_ROLLING_TAG}" --token "${GOLDSKY_API_KEY}" --force 2>/dev/null || true + goldsky subgraph delete "${CONFLICT}" --token "${GOLDSKY_API_KEY}" --force 2>/dev/null || true + sleep 3 + echo "📤 Retrying deploy..." + goldsky subgraph deploy "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --path . \ + --token "${GOLDSKY_API_KEY}" + else + echo "" + echo "❌ Deployment failed" + exit 1 + fi + fi + + # Brief pause for Goldsky to register the new deployment + if [ "$SKIP_DEPLOY" = "false" ]; then + sleep 5 + fi + + # --- Roll the rolling tag --- + echo "" + echo "🏷️ Moving tag ${GOLDSKY_ROLLING_TAG} → ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" + + set +e + TAG_OUTPUT=$(goldsky subgraph tag create "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" 2>&1) + TAG_EXIT=$? + set -e + + if [ $TAG_EXIT -ne 0 ]; then + echo " Tag may exist on another version — moving it..." + LIST_OUTPUT=$(goldsky subgraph list --token "${GOLDSKY_API_KEY}" 2>/dev/null || true) + OLD_TAGGED=$(echo "$LIST_OUTPUT" \ + | grep "${GOLDSKY_SUBGRAPH_NAME}/" \ + | grep "${GOLDSKY_ROLLING_TAG}" \ + | awk '{print $1}' | head -1) + if [ -n "$OLD_TAGGED" ]; then + echo " Removing tag from ${OLD_TAGGED}..." + goldsky subgraph tag delete "${OLD_TAGGED}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" \ + --force 2>/dev/null || true + fi + goldsky subgraph tag create "${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION}" \ + --tag "${GOLDSKY_ROLLING_TAG}" \ + --token "${GOLDSKY_API_KEY}" + fi + + echo "" + echo "✅ ${GOLDSKY_SUBGRAPH_NAME}/${SUBGRAPH_VERSION} deployed and tagged as ${GOLDSKY_ROLLING_TAG}" + + if [ "$SKIP_DEPLOY" = "true" ]; then + echo "deploy_status=already_exists" >> $GITHUB_OUTPUT + else + echo "deploy_status=deployed" >> $GITHUB_OUTPUT + fi + + echo "## 🚀 Goldsky Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Subgraph | \`${GOLDSKY_SUBGRAPH_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${SUBGRAPH_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${GOLDSKY_ROLLING_TAG}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Status | $([ "$SKIP_DEPLOY" = "true" ] && echo "Already existed" || echo "Deployed") |" >> $GITHUB_STEP_SUMMARY + + verify: + name: 🔍 Verify + runs-on: ubuntu-latest + needs: [build, deploy] + if: github.event_name != 'pull_request' + + steps: + - name: Verify deployment on Goldsky + env: + GOLDSKY_ENDPOINT: ${{ needs.build.outputs.goldsky_endpoint }} + run: | + echo "🔍 Verifying subgraph on Goldsky..." + echo " Subgraph: ${{ needs.build.outputs.goldsky_subgraph_name }}" + + if [ -z "${GOLDSKY_ENDPOINT}" ]; then + echo "⚠️ No Goldsky endpoint URL configured — skipping verification" + echo " Set DEV_GS_POINTS / STG_GS_POINTS / LMN_GS_POINTS org variable" + exit 0 + fi + + echo " Endpoint: ${GOLDSKY_ENDPOINT}" + + # Poll for up to 60 seconds, checking every 10s + for i in 1 2 3 4 5 6; do + sleep 10 + echo "" + echo " Attempt $i/6..." + + RESPONSE=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "{ _meta { block { number } deployment hasIndexingErrors } }"}' \ + "${GOLDSKY_ENDPOINT}") + + if echo "$RESPONSE" | jq -e '.errors' > /dev/null 2>&1; then + echo " Subgraph not ready yet..." + continue + fi + + HAS_ERRORS=$(echo "$RESPONSE" | jq -r '.data._meta.hasIndexingErrors // false') + BLOCK_NUMBER=$(echo "$RESPONSE" | jq -r '.data._meta.block.number // "unknown"') + + echo " Block: $BLOCK_NUMBER" + echo " Indexing errors: $HAS_ERRORS" + + if [ "$HAS_ERRORS" == "true" ]; then + echo "⚠️ Subgraph has indexing errors — check Goldsky dashboard" + exit 1 + fi + + echo "✅ Subgraph deployed and indexing on Goldsky" + exit 0 + done + + echo "⚠️ Subgraph did not become ready within 60s — may still be syncing" + echo " This is normal for new deployments. Check Goldsky dashboard." + exit 0 + + cleanup: + name: 🧹 Cleanup + runs-on: ubuntu-latest + needs: [setup, build, verify] + if: always() && github.event_name != 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + if: needs.verify.result == 'success' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: needs.verify.result == 'success' + run: | + TAG_NAME="${{ needs.setup.outputs.tag }}" + echo "🏷️ Creating tag: $TAG_NAME" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "⚠️ Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${{ needs.setup.outputs.version }} - Deployed to ${{ needs.setup.outputs.environment }}" + git push origin "$TAG_NAME" + echo "✅ Tag $TAG_NAME pushed" + fi + + - name: Summary + run: | + if [ "${{ needs.verify.result }}" == "success" ]; then + echo "## 🎉 Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Service:** Points Subgraph" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ needs.setup.outputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.setup.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Target:** Goldsky" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Check logs above for details." >> $GITHUB_STEP_SUMMARY + fi + + notify: + name: 📢 Notify + runs-on: ubuntu-latest + needs: [setup, build, deploy, verify, cleanup] + if: always() && github.event_name != 'pull_request' + + steps: + - name: Checkout (for composite action) + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine status + id: status + run: | + if [ "${{ needs.verify.result }}" == "success" ]; then + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "failure" ] || [ "${{ needs.deploy.result }}" == "failure" ] || [ "${{ needs.build.result }}" == "failure" ]; then + echo "status=failure" >> $GITHUB_OUTPUT + elif [ "${{ needs.verify.result }}" == "cancelled" ] || [ "${{ needs.deploy.result }}" == "cancelled" ]; then + echo "status=cancelled" >> $GITHUB_OUTPUT + else + echo "status=skipped" >> $GITHUB_OUTPUT + fi + + - name: Send Slack notification + uses: ./.github/actions/slack-notify + with: + status: ${{ steps.status.outputs.status }} + environment: ${{ needs.setup.outputs.environment }} + service_name: "Points Subgraph" + version: ${{ needs.setup.outputs.version }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + additional_info: "${{ format('*Subgraph:* `{0}/{1}` → `{2}` • *Status:* {3}', needs.build.outputs.goldsky_subgraph_name, needs.build.outputs.version, needs.build.outputs.goldsky_rolling_tag, needs.deploy.outputs.deploy_status) }}" diff --git a/.github/workflows/points-indexer-tests.yml b/.github/workflows/points-indexer-tests.yml new file mode 100644 index 0000000..ee9b04e --- /dev/null +++ b/.github/workflows/points-indexer-tests.yml @@ -0,0 +1,98 @@ +name: Points indexer tests + +on: + pull_request: + paths: + - "points-indexer/**" + - "contracts/**" + - ".github/workflows/points-indexer-tests.yml" + push: + branches: + - main + - stg + - dev + paths: + - "points-indexer/**" + - "contracts/**" + - ".github/workflows/points-indexer-tests.yml" + +jobs: + pre: + name: Skip duplicate runs + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip-check.outputs.should_skip }} + steps: + - id: skip-check + uses: fkirc/skip-duplicate-actions@v5 + with: + concurrent_skipping: never + skip_after_successful_duplicate: "true" + paths_ignore: '["**/*.md"]' + + test: + name: Points indexer tests + needs: pre + if: needs.pre.outputs.should_skip != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: points-indexer/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: | + points-indexer/pnpm-lock.yaml + contracts/pnpm-lock.yaml + + # Integration tests deploy the real points contracts from their compiled + # Hardhat artifacts (contracts/artifacts/**), so the contracts package must + # be installed and compiled first. + - name: Install contracts dependencies + working-directory: ./contracts + run: pnpm install --frozen-lockfile + + - name: Compile contracts + working-directory: ./contracts + run: pnpm compile + + - name: Install points-indexer dependencies + working-directory: ./points-indexer + run: pnpm install --frozen-lockfile + + # Render subgraph.yaml from the template using the dummy values shipped in + # .env.example so codegen (and the matchstick harness) has something to + # chew on. Real addresses are only needed at deploy time. + - name: Prepare subgraph (with .env.example defaults) + working-directory: ./points-indexer + run: | + set -a && source .env.example && set +a + pnpm prepare:env + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Codegen + working-directory: ./points-indexer + run: pnpm codegen + + - name: Build + working-directory: ./points-indexer + run: pnpm build + + - name: Run matchstick unit tests + working-directory: ./points-indexer + run: pnpm test + + - name: Run integration tests + working-directory: ./points-indexer + run: pnpm test:integration From bf1f6cd1f315f477e296ef57bac4e8f4a8358d59 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 11 Jun 2026 15:23:19 +0200 Subject: [PATCH 063/180] feat(points): add maker price-improvement multiplier to PointsHook Scale maker points by how close the resting quote sits to a manipulation-resistant oracle reference: full `maxMakerMult` at zero spread, tapering linearly to 1x at `maxSpread`, 1x beyond. Disabled by default and tuned via `setPriceImprovement`, so deploys behave like the prior linear hook until turned on. `onFill` gains `makerPrice`/`refPrice`; `refPrice == 0` (signalled by the venue on a stale oracle) drops the bonus to 1x rather than reverting, so a points read can never block a fill. Taker/keeper accrual is unchanged. --- contracts/abi/IPointsHook.json | 10 +++ contracts/abi/IPointsHook.ts | 10 +++ contracts/abi/PointsHook.json | 73 +++++++++++++++ contracts/abi/PointsHook.ts | 73 +++++++++++++++ contracts/contracts/PointsHook.sol | 62 +++++++++++-- .../contracts/interfaces/IPointsHook.sol | 16 +++- contracts/tests/pointsHook.test.ts | 90 +++++++++++++++++-- docs/points-system-design.md | 21 ++++- .../integration/points-mint.test.ts | 6 +- .../integration/points-redemption.test.ts | 4 +- 10 files changed, 340 insertions(+), 25 deletions(-) diff --git a/contracts/abi/IPointsHook.json b/contracts/abi/IPointsHook.json index 75c7ef7..1769c29 100644 --- a/contracts/abi/IPointsHook.json +++ b/contracts/abi/IPointsHook.json @@ -25,6 +25,16 @@ "internalType": "uint256", "name": "takerFee", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "type": "uint256" } ], "name": "onFill", diff --git a/contracts/abi/IPointsHook.ts b/contracts/abi/IPointsHook.ts index 633ab56..b7f9c0a 100644 --- a/contracts/abi/IPointsHook.ts +++ b/contracts/abi/IPointsHook.ts @@ -25,6 +25,16 @@ export const IPointsHookAbi = [ "internalType": "uint256", "name": "takerFee", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "type": "uint256" } ], "name": "onFill", diff --git a/contracts/abi/PointsHook.json b/contracts/abi/PointsHook.json index 7c0e019..f66aadd 100644 --- a/contracts/abi/PointsHook.json +++ b/contracts/abi/PointsHook.json @@ -82,6 +82,25 @@ "name": "MinFeeSet", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "maxMakerMult", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxSpread", + "type": "uint256" + } + ], + "name": "PriceImprovementSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -289,6 +308,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "maxMakerMult", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxSpread", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "minFee", @@ -328,6 +373,16 @@ "internalType": "uint256", "name": "takerFee", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "type": "uint256" } ], "name": "onFill", @@ -428,6 +483,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxMakerMult", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxSpread", + "type": "uint256" + } + ], + "name": "setPriceImprovement", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/abi/PointsHook.ts b/contracts/abi/PointsHook.ts index 9ceb3d5..94e168a 100644 --- a/contracts/abi/PointsHook.ts +++ b/contracts/abi/PointsHook.ts @@ -82,6 +82,25 @@ export const PointsHookAbi = [ "name": "MinFeeSet", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "maxMakerMult", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxSpread", + "type": "uint256" + } + ], + "name": "PriceImprovementSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -289,6 +308,32 @@ export const PointsHookAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "maxMakerMult", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxSpread", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "minFee", @@ -328,6 +373,16 @@ export const PointsHookAbi = [ "internalType": "uint256", "name": "takerFee", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "type": "uint256" } ], "name": "onFill", @@ -428,6 +483,24 @@ export const PointsHookAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxMakerMult", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxSpread", + "type": "uint256" + } + ], + "name": "setPriceImprovement", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/contracts/PointsHook.sol b/contracts/contracts/PointsHook.sol index 34d776c..b2be49b 100644 --- a/contracts/contracts/PointsHook.sol +++ b/contracts/contracts/PointsHook.sol @@ -45,6 +45,14 @@ contract PointsHook is IPointsHook, AccessControl { uint256 public keeperPoints; /// @notice Minimum fee (collateral decimals) a side must pay to earn on a fill. uint256 public minFee; + /// @notice Maker multiplier (WAD) at zero spread, e.g. `3e18` == 3x. The bonus is + /// disabled (every maker fill earns the flat `wMaker` rate) whenever this is + /// `<= WEIGHT_SCALE`. Defaults to 0 (disabled), so deploys behave like a + /// plain linear hook until `setPriceImprovement` turns the bonus on. + uint256 public maxMakerMult; + /// @notice Spread (WAD fraction of `refPrice`) at/above which the maker multiplier + /// returns to 1x. `0` also disables the bonus. + uint256 public maxSpread; // ── Errors / events ───────────────────────────────────────────────────────── @@ -53,6 +61,7 @@ contract PointsHook is IPointsHook, AccessControl { event WeightsSet(uint256 wMaker, uint256 wTaker); event KeeperPointsSet(uint256 keeperPoints); event MinFeeSet(uint256 minFee); + event PriceImprovementSet(uint256 maxMakerMult, uint256 maxSpread); /// @param _points The POINTS token (this hook must be set as its `minter`). /// @param admin Receives `DEFAULT_ADMIN_ROLE` (parameter tuning + role grants). @@ -80,11 +89,15 @@ contract PointsHook is IPointsHook, AccessControl { // ── Venue entry points ────────────────────────────────────────────────────── /// @inheritdoc IPointsHook - function onFill(address maker, address taker, uint256 notional, int256 makerFee, uint256 takerFee) - external - override - onlyRole(HOOK_CALLER_ROLE) - { + function onFill( + address maker, + address taker, + uint256 notional, + int256 makerFee, + uint256 takerFee, + uint256 makerPrice, + uint256 refPrice + ) external override onlyRole(HOOK_CALLER_ROLE) { // Self-match exclusion: a wallet trading with itself earns nothing. if (maker == taker) return; @@ -98,15 +111,35 @@ contract PointsHook is IPointsHook, AccessControl { } // Maker side. A negative makerFee (rebate) earns nothing and violates the - // positive-fees invariant the program runs under. + // positive-fees invariant the program runs under. Tighter quotes (closer to + // the reference price) earn a higher multiplier — see `_makerMultiplier`. if (makerFee > 0 && uint256(makerFee) >= minFee) { - uint256 amount = (notional * wMaker) / WEIGHT_SCALE; + uint256 mult = _makerMultiplier(makerPrice, refPrice); + uint256 amount = (notional * wMaker * mult) / (WEIGHT_SCALE * WEIGHT_SCALE); if (amount > 0) { points.mint(maker, amount); } } } + /// @dev Maker price-improvement multiplier (WAD; `WEIGHT_SCALE` == 1x). Rewards + /// quotes resting closer to the reference price: full `maxMakerMult` at zero + /// spread, tapering linearly to 1x at `maxSpread`, and 1x beyond. Returns a + /// neutral 1x when the bonus is disabled or no reference price is available + /// (`refPrice == 0`), so a missing/stale oracle simply drops the bonus. + function _makerMultiplier(uint256 makerPrice, uint256 refPrice) internal view returns (uint256) { + uint256 cap = maxMakerMult; + uint256 width = maxSpread; + if (cap <= WEIGHT_SCALE || width == 0 || refPrice == 0) return WEIGHT_SCALE; + + uint256 diff = makerPrice > refPrice ? makerPrice - refPrice : refPrice - makerPrice; + uint256 spread = (diff * WEIGHT_SCALE) / refPrice; + if (spread >= width) return WEIGHT_SCALE; + + // Linear taper from `cap` (spread 0) down to 1x (spread == width). + return cap - ((cap - WEIGHT_SCALE) * spread) / width; + } + /// @inheritdoc IPointsHook function onLiquidation(address liquidator, uint256 /* fee */ ) external @@ -136,4 +169,19 @@ contract PointsHook is IPointsHook, AccessControl { minFee = _minFee; emit MinFeeSet(_minFee); } + + /// @notice Configure the maker price-improvement multiplier. + /// @param _maxMakerMult Multiplier (WAD) applied to maker points at zero spread + /// (e.g. `3e18` == 3x). Pass `0` or any value `<= WEIGHT_SCALE` to disable + /// the bonus so makers earn the flat `wMaker` rate. + /// @param _maxSpread Spread (WAD fraction of the reference price) at/above which the + /// multiplier returns to 1x. Pass `0` to disable the bonus. + function setPriceImprovement(uint256 _maxMakerMult, uint256 _maxSpread) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + maxMakerMult = _maxMakerMult; + maxSpread = _maxSpread; + emit PriceImprovementSet(_maxMakerMult, _maxSpread); + } } diff --git a/contracts/contracts/interfaces/IPointsHook.sol b/contracts/contracts/interfaces/IPointsHook.sol index 9fe0b2d..3e679a2 100644 --- a/contracts/contracts/interfaces/IPointsHook.sol +++ b/contracts/contracts/interfaces/IPointsHook.sol @@ -17,7 +17,21 @@ interface IPointsHook { /// @param makerFee Maker fee actually paid (collateral decimals, signed; a /// rebate would be negative — disallowed while points are live). /// @param takerFee Taker fee actually paid (collateral decimals). - function onFill(address maker, address taker, uint256 notional, int256 makerFee, uint256 takerFee) external; + /// @param makerPrice The resting maker order's price, in the venue's price units. + /// @param refPrice A manipulation-resistant reference (oracle) price in the SAME + /// units as `makerPrice`, used for the maker price-improvement + /// multiplier. Pass 0 when no fresh reference is available (e.g. a + /// stale oracle); the hook then applies no bonus (1x) rather than + /// reverting, so a points read can never block a fill. + function onFill( + address maker, + address taker, + uint256 notional, + int256 makerFee, + uint256 takerFee, + uint256 makerPrice, + uint256 refPrice + ) external; /// @notice Called when a keeper executes a liquidation on either venue. /// @param liquidator The address that executed the liquidation. diff --git a/contracts/tests/pointsHook.test.ts b/contracts/tests/pointsHook.test.ts index 8c420c2..bd2da7b 100644 --- a/contracts/tests/pointsHook.test.ts +++ b/contracts/tests/pointsHook.test.ts @@ -11,18 +11,25 @@ import { const { viem, networkHelpers } = await network.connect(); +const WAD = 10n ** 18n; /** Expected taker points for the fixture's NOTIONAL + W_TAKER. */ -const TAKER_PTS = (NOTIONAL * W_TAKER) / 10n ** 18n; // 1000 POINTS -/** Expected maker points for the fixture's NOTIONAL + W_MAKER. */ -const MAKER_PTS = (NOTIONAL * W_MAKER) / 10n ** 18n; // 1500 POINTS +const TAKER_PTS = (NOTIONAL * W_TAKER) / WAD; // 1000 POINTS +/** Expected maker points for the fixture's NOTIONAL + W_MAKER (1x multiplier). */ +const MAKER_PTS = (NOTIONAL * W_MAKER) / WAD; // 1500 POINTS const FEE = 1_000_000n; // 1 USDC, comfortably above any threshold +// Price-improvement config used by the multiplier tests: 3x at zero spread, tapering +// to 1x at a 1% spread from the reference price. +const MAX_MULT = 3n * WAD; +const MAX_SPREAD = WAD / 100n; // 1% +const REF_PRICE = 1000n; + describe("PointsHook", () => { describe("authorization", () => { it("rejects onFill from a non-venue caller", async () => { const { hook, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); await viem.assertions.revertWithCustomError( - hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { account: alice.account, }), hook, @@ -44,7 +51,7 @@ describe("PointsHook", () => { it("mints weighted points to maker and taker", async () => { const { hook, points, venue, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); // alice = maker, bob = taker - await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { account: venue.account, }); assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); @@ -53,7 +60,7 @@ describe("PointsHook", () => { it("skips minting on a self-match", async () => { const { hook, points, venue, alice } = await networkHelpers.loadFixture(deployHookFixture); - await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE], { + await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { account: venue.account, }); assert.equal(await points.read.balanceOf([alice.account.address]), 0n); @@ -63,7 +70,7 @@ describe("PointsHook", () => { it("does not reward a maker rebate (non-positive makerFee)", async () => { const { hook, points, venue, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); // makerFee = -1 (rebate): maker earns nothing, taker still earns. - await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, -1n, FEE], { + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, -1n, FEE, 0n, 0n], { account: venue.account, }); assert.equal(await points.read.balanceOf([alice.account.address]), 0n); @@ -76,7 +83,7 @@ describe("PointsHook", () => { await hook.write.setMinFee([FEE], { account: owner.account }); // taker pays below threshold, maker pays at threshold. - await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE - 1n], { + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE - 1n, 0n, 0n], { account: venue.account, }); assert.equal(await points.read.balanceOf([bob.account.address]), 0n); @@ -84,6 +91,73 @@ describe("PointsHook", () => { }); }); + describe("maker price-improvement multiplier", () => { + it("is neutral (1x) by default, even when prices are supplied", async () => { + const { hook, points, venue, alice, bob } = await networkHelpers.loadFixture(deployHookFixture); + // Multiplier unconfigured (maxMakerMult == 0): a tight quote still earns the flat rate. + await hook.write.onFill( + [alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, REF_PRICE, REF_PRICE], + { account: venue.account }, + ); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); + }); + + it("applies the full multiplier when the maker quotes at the reference price", async () => { + const { hook, points, owner, venue, alice, bob } = + await networkHelpers.loadFixture(deployHookFixture); + await hook.write.setPriceImprovement([MAX_MULT, MAX_SPREAD], { account: owner.account }); + + // spread == 0 → 3x maker points; taker is unaffected. + await hook.write.onFill( + [alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, REF_PRICE, REF_PRICE], + { account: venue.account }, + ); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS * 3n); + assert.equal(await points.read.balanceOf([bob.account.address]), TAKER_PTS); + }); + + it("tapers linearly between zero spread and maxSpread", async () => { + const { hook, points, owner, venue, alice, bob } = + await networkHelpers.loadFixture(deployHookFixture); + await hook.write.setPriceImprovement([MAX_MULT, MAX_SPREAD], { account: owner.account }); + + // makerPrice 0.5% above ref → halfway through the taper → 2x. + const makerPrice = REF_PRICE + (REF_PRICE * MAX_SPREAD) / (2n * WAD); // 1005 + await hook.write.onFill( + [alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, makerPrice, REF_PRICE], + { account: venue.account }, + ); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS * 2n); + }); + + it("falls back to 1x at or beyond maxSpread", async () => { + const { hook, points, owner, venue, alice, bob } = + await networkHelpers.loadFixture(deployHookFixture); + await hook.write.setPriceImprovement([MAX_MULT, MAX_SPREAD], { account: owner.account }); + + // makerPrice 1% from ref == maxSpread → 1x (wide quotes earn only the base rate). + const makerPrice = REF_PRICE + (REF_PRICE * MAX_SPREAD) / WAD; // 1010 + await hook.write.onFill( + [alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, makerPrice, REF_PRICE], + { account: venue.account }, + ); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); + }); + + it("drops the bonus to 1x when no reference price is available (stale oracle)", async () => { + const { hook, points, owner, venue, alice, bob } = + await networkHelpers.loadFixture(deployHookFixture); + await hook.write.setPriceImprovement([MAX_MULT, MAX_SPREAD], { account: owner.account }); + + // refPrice == 0 signals a stale/absent oracle: maker still earns, just no bonus. + await hook.write.onFill( + [alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, REF_PRICE, 0n], + { account: venue.account }, + ); + assert.equal(await points.read.balanceOf([alice.account.address]), MAKER_PTS); + }); + }); + describe("onLiquidation", () => { it("mints flat keeper points", async () => { const { hook, points, venue, keeper } = await networkHelpers.loadFixture(deployHookFixture); diff --git a/docs/points-system-design.md b/docs/points-system-design.md index 258d18e..81bcb06 100644 --- a/docs/points-system-design.md +++ b/docs/points-system-design.md @@ -99,7 +99,7 @@ function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) { /* - **Non-upgradeable, plain deploy.** Holds `MINTER_ROLE` on `POINTS`. Contains all the points math and weight parameters. Not a fund-holding contract. - Implements `IPointsHook` with two entry points called by the venues: - - `onFill(maker, taker, notional, makerFee, takerFee)` — called by perps `_executeMatch` and futures lot creation. Skips entirely on a self-match (`maker == taker`); otherwise mints `notional * w_taker / WEIGHT_SCALE` to the taker when `takerFee >= minFee`, and `notional * w_maker / WEIGHT_SCALE` to the maker when `makerFee > 0 && makerFee >= minFee` (a maker rebate earns nothing). Each side mints via `points.mint`, which emits the POINTS `Transfer(0x0 -> account)` the leaderboard subgraph indexes; the hook emits no separate accrual event. Note: one perps `createOrder` can walk the book and match against N resting maker orders in a single transaction, producing N `onFill` calls — so minting is O(matched levels) per taker transaction. + - `onFill(maker, taker, notional, makerFee, takerFee, makerPrice, refPrice)` — called by perps `_executeMatch` and futures lot creation. Skips entirely on a self-match (`maker == taker`); otherwise mints `notional * w_taker / WEIGHT_SCALE` to the taker when `takerFee >= minFee`, and `notional * w_maker * mult / WEIGHT_SCALE^2` to the maker when `makerFee > 0 && makerFee >= minFee` (a maker rebate earns nothing). `mult` is the **maker price-improvement multiplier** (see [Section 5.2.1](#521-maker-price-improvement-multiplier)). Each side mints via `points.mint`, which emits the POINTS `Transfer(0x0 -> account)` the leaderboard subgraph indexes; the hook emits no separate accrual event. Note: one perps `createOrder` can walk the book and match against N resting maker orders in a single transaction, producing N `onFill` calls — so minting is O(matched levels) per taker transaction. - `onLiquidation(liquidator, fee)` — called by perps `liquidatePosition` and futures `liquidatePosition` / `liquidateOrder`. Mints flat keeper points to the liquidator. - **Caller authorization**: the hook checks that the caller holds a `HOOK_CALLER_ROLE`, granted only to the two venue contracts, so arbitrary addresses cannot mint points by calling the hook directly. - **Retuning**: changing `w_maker`, `w_taker`, or the keeper rate is done by deploying a new `PointsHook` and calling `setHook()` on each venue. No proxy is required because the hook is designed to be **replaced**, not upgraded. @@ -111,13 +111,24 @@ interface IPointsHook { address taker, uint256 notional, int256 makerFee, - uint256 takerFee + uint256 takerFee, + uint256 makerPrice, // resting maker order price (venue price units) + uint256 refPrice // oracle reference, same units; 0 ⇒ no bonus (stale oracle) ) external; function onLiquidation(address liquidator, uint256 fee) external; } ``` +#### 5.2.1 Maker price-improvement multiplier + +To reward *tight* liquidity (not just filled volume), the maker side of `onFill` is scaled by a multiplier based on how close the resting maker quote was to a manipulation-resistant reference price: + +- `spread = |makerPrice - refPrice| / refPrice`. The multiplier is `maxMakerMult` (WAD; e.g. `3e18` == 3x) at `spread == 0`, tapers **linearly** to 1x at `spread == maxSpread`, and is 1x beyond. Both `maxMakerMult` and `maxSpread` are admin-tunable hook parameters; the bonus is **disabled by default** (`maxMakerMult == 0`), so the hook behaves as a plain linear model until `setPriceImprovement` turns it on. +- The reference is an **oracle**, never the order-book mid (which a maker can push toward their own order). Each venue sources it from its existing oracle: perps from the price oracle (`getMarketPrice`'s feed), futures from the hashrate oracle. The taker side is **not** multiplied. +- **Oracle degradation contract.** The venue passes `refPrice = 0` when its oracle is stale/invalid (via a non-reverting read, *not* the reverting `getMarketPrice()`), and the hook then applies a neutral 1x. This keeps the rule: the incentive layer **fails soft** (base maker points still mint, bonus drops) and can never block a fill the matching engine would otherwise allow — whereas the engine's own margin/funding/liquidation paths **fail closed** on a stale oracle, as before. Base maker/taker/keeper accrual never depends on the oracle. +- Why "near", not "far": rewarding quotes *close* to fair value tightens spreads (useful liquidity); rewarding distance would pay for liquidity that rarely fills. The multiplier is still minted only on a fee-paying fill, so it inherits the same wash-resistance as the base maker points. + ### 5.3 Venue wiring (perps, futures) Venue changes are deliberately minimal and live in the venue repos, not here: @@ -128,7 +139,9 @@ Venue changes are deliberately minimal and live in the venue repos, not here: ```solidity if (address(hook) != address(0)) { - hook.onFill(maker, taker, notional, makerFee, takerFee); + // `makerPrice` is the resting maker order's price; `_refPriceForPoints()` reads the + // venue oracle but returns 0 (rather than reverting) when stale, so points never block a fill. + hook.onFill(maker, taker, notional, makerFee, takerFee, makerPrice, _refPriceForPoints()); } ``` @@ -242,6 +255,6 @@ flowchart TD ## 12. Open items / preconditions - **Same-chain deployment** of perps and futures with the single `PointsHook` is required for cross-venue minting (both venues call the same hook). The points subgraph itself only needs the points contracts, which deploy together. -- Final weight values (`w_maker`, `w_taker`, keeper rate) and the minimum-fee threshold (`minFee`) are parameters to be set on `PointsHook` at deploy / via the admin setters. +- Final weight values (`w_maker`, `w_taker`, keeper rate), the minimum-fee threshold (`minFee`), and the maker price-improvement multiplier (`maxMakerMult`, `maxSpread` — disabled by default) are parameters to be set on `PointsHook` at deploy / via the admin setters. - The treasury GOV pool size and the decision to enable conversion at all remain discretionary. - Deferred features (referral, loyalty, per-account caps, sybil/cluster detection) are tracked in `points-system-improvements.md` for a future iteration. diff --git a/points-indexer/integration/points-mint.test.ts b/points-indexer/integration/points-mint.test.ts index 02d867f..da395cd 100644 --- a/points-indexer/integration/points-mint.test.ts +++ b/points-indexer/integration/points-mint.test.ts @@ -35,7 +35,7 @@ describe("onFill accrual: maker + taker mints mirrored to the leaderboard", () = await conn.matchstick.anchor(); // alice = maker, bob = taker. Both fees above threshold → both sides mint. - await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE], { + await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { account: venue.account, }); @@ -99,11 +99,11 @@ describe("onFill accrual: a self-match contributes nothing to the leaderboard", await conn.matchstick.anchor(); // A self-match by alice (maker == taker) mints nothing... - await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE], { + await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { account: venue.account, }); // ...while a real fill (carol maker w/ 0 fee → no maker mint; bob takes) mints once. - await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL, 0n, FEE], { + await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL, 0n, FEE, 0n, 0n], { account: venue.account, }); diff --git a/points-indexer/integration/points-redemption.test.ts b/points-indexer/integration/points-redemption.test.ts index cd37dc2..4cbbfef 100644 --- a/points-indexer/integration/points-redemption.test.ts +++ b/points-indexer/integration/points-redemption.test.ts @@ -39,10 +39,10 @@ describe("swap: burn debits supply, Swapped records the GOV payout split", () => await conn.matchstick.anchor(); // Accrue: carol is the maker (makerFee 0 → no maker mint); alice/bob take. - await hook.write.onFill([carol.account.address, alice.account.address, NOTIONAL, 0n, FEE], { + await hook.write.onFill([carol.account.address, alice.account.address, NOTIONAL, 0n, FEE, 0n, 0n], { account: venue.account, }); - await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE], { + await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE, 0n, 0n], { account: venue.account, }); From 4fa034237bb3a734070134aa794f0b2b4001b36e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 11 Jun 2026 15:32:49 +0200 Subject: [PATCH 064/180] feat(points): add deploy-points-hook script (redeploy hook, keep token) Deploys a new PointsHook against an EXISTING Points token (preserving balances + leaderboard), grants MINTER_ROLE, optionally sets minFee and the maker price-improvement multiplier, and grants HOOK_CALLER_ROLE to the venues. Use when the hook formula/code changes. --- contracts/package.json | 1 + contracts/scripts/deploy-points-hook.ts | 162 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 contracts/scripts/deploy-points-hook.ts diff --git a/contracts/package.json b/contracts/package.json index 4224946..059d864 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -13,6 +13,7 @@ "deploy:vault": "hardhat run scripts/deploy-collateral-vault.ts", "deploy:pme": "hardhat run scripts/deploy-portfolio-margin-engine.ts", "deploy:points": "hardhat run scripts/deploy-points.ts", + "deploy:points-hook": "hardhat run scripts/deploy-points-hook.ts", "upgrade:vault": "hardhat run scripts/update-collateral-vault.ts", "upgrade:pme": "hardhat run scripts/update-portfolio-margin-engine.ts" }, diff --git a/contracts/scripts/deploy-points-hook.ts b/contracts/scripts/deploy-points-hook.ts new file mode 100644 index 0000000..4d30d9d --- /dev/null +++ b/contracts/scripts/deploy-points-hook.ts @@ -0,0 +1,162 @@ +import fs from "node:fs"; +import hre from "hardhat"; +import { readOptionalAddress, readOptionalBigInt, requireAddress } from "../lib/env.ts"; +import { writeAndWait } from "../lib/writeContract.ts"; +import { verifyContract } from "../lib/verify.ts"; +import { addrUrl, txUrl } from "../lib/explorer.ts"; +import { logInfo, logPrompt, logStep, logSuccess, logTitle } from "../lib/log.ts"; + +/** + * Redeploy ONLY the `PointsHook` against an EXISTING `Points` token, then rewire roles. + * + * Unlike `deploy-points.ts` (which deploys a fresh Points token), this preserves all + * existing balances and the leaderboard. Use it whenever the hook formula/code changes + * (the hook is designed to be replaced, not upgraded). + * + * Steps (deployer must hold DEFAULT_ADMIN_ROLE on Points): + * 1. Deploy the new PointsHook against POINTS_ADDRESS. + * 2. Grant it MINTER_ROLE on the existing Points token. + * 3. Optionally set minFee and the maker price-improvement multiplier. + * 4. Grant HOOK_CALLER_ROLE to the venues (perps / futures). + * + * AFTER this, point each venue at the new hook (which already holds the roles) by running + * the venue upgrade scripts with HOOK_ADDRESS = . Because `onFill` changed shape, + * the venue MUST be upgraded to the matching implementation in the same operation as setHook. + */ + +/** Fixed-point scale: 1e18 == 1x for weights and the multiplier. */ +const WEIGHT_SCALE = 1_000_000_000_000_000_000n; +/** 1.5 POINTS per notional unit (maker), biasing toward liquidity. */ +const DEFAULT_W_MAKER = 1_500_000_000_000_000_000n; +/** 1 POINT per notional unit (taker). */ +const DEFAULT_W_TAKER = 1_000_000_000_000_000_000n; +/** 5 POINTS (6 decimals) per liquidation. */ +const DEFAULT_KEEPER_POINTS = 5_000_000n; +/** 3x maker multiplier at zero spread. */ +const DEFAULT_MAX_MAKER_MULT = 3_000_000_000_000_000_000n; +/** 1% spread (WAD fraction) at/above which the multiplier returns to 1x. */ +const DEFAULT_MAX_SPREAD = 10_000_000_000_000_000n; + +async function main() { + logTitle("PointsHook Redeploy (existing Points token)"); + + const { viem } = await hre.network.getOrCreate(); + + const pointsAddress = requireAddress("POINTS_ADDRESS"); + const wMaker = readOptionalBigInt("POINTS_W_MAKER") ?? DEFAULT_W_MAKER; + const wTaker = readOptionalBigInt("POINTS_W_TAKER") ?? DEFAULT_W_TAKER; + const keeperPoints = readOptionalBigInt("POINTS_KEEPER") ?? DEFAULT_KEEPER_POINTS; + const minFee = readOptionalBigInt("POINTS_MIN_FEE"); + const maxMakerMult = readOptionalBigInt("POINTS_MAX_MAKER_MULT") ?? DEFAULT_MAX_MAKER_MULT; + const maxSpread = readOptionalBigInt("POINTS_MAX_SPREAD") ?? DEFAULT_MAX_SPREAD; + + const PERPS_ADDRESS = readOptionalAddress("PERPS_ADDRESS"); + const FUTURES_ADDRESS = readOptionalAddress("FUTURES_ADDRESS"); + + const [deployer] = await viem.getWalletClients(); + const pc = await viem.getPublicClient(); + const admin = deployer.account.address; + logInfo("deployer", { Address: addrUrl(pc, admin) }); + + // The multiplier is only active when maxMakerMult > 1x AND maxSpread > 0 (mirrors the + // hook's own enable condition); otherwise the hook stays on the plain linear path. + const multiplierEnabled = maxMakerMult > WEIGHT_SCALE && maxSpread > 0n; + + const points = await viem.getContractAt("Points", pointsAddress); + + // Fail fast if the deployer cannot grant MINTER_ROLE (admin == EOA assumption). + const ADMIN_ROLE = await points.read.DEFAULT_ADMIN_ROLE(); + const deployerIsAdmin = await points.read.hasRole([ADMIN_ROLE, admin]); + if (!deployerIsAdmin) { + throw new Error( + `Deployer ${admin} does not hold DEFAULT_ADMIN_ROLE on Points ${pointsAddress}. ` + + "Grant MINTER_ROLE to the new hook via the admin (e.g. Safe) instead.", + ); + } + + logInfo("existing Points token", { + Address: addrUrl(pc, pointsAddress), + finalized: (await points.read.finalized()).toString(), + totalSupply: (await points.read.totalSupply()).toString(), + }); + logInfo("hook parameters", { + wMaker: wMaker.toString(), + wTaker: wTaker.toString(), + keeperPoints: keeperPoints.toString(), + minFee: minFee?.toString() ?? "(0)", + priceImprovement: multiplierEnabled + ? `maxMakerMult=${maxMakerMult} maxSpread=${maxSpread}` + : "(disabled)", + }); + logInfo("venues (granted HOOK_CALLER_ROLE if set)", { + Perps: PERPS_ADDRESS ?? "(none)", + Futures: FUTURES_ADDRESS ?? "(none)", + }); + + await logPrompt("Review the configuration above. Proceed with deployment?"); + + // ── 1. Deploy the new PointsHook against the existing Points token ─────────── + logInfo("Deploy PointsHook", { points: pointsAddress }); + await logPrompt("Proceed?"); + const hookArgs = [pointsAddress, admin, wMaker, wTaker, keeperPoints] as const; + const hook = await viem.deployContract("PointsHook", hookArgs, { confirmations: 5 }); + logStep("Deployed", addrUrl(pc, hook.address)); + await verifyContract(hook.address, [...hookArgs]); + + // ── 2. Grant MINTER_ROLE to the new hook ──────────────────────────────────── + const MINTER_ROLE = await points.read.MINTER_ROLE(); + logInfo("Points.grantRole(MINTER_ROLE, hook)", { hook: hook.address }); + await logPrompt("Proceed?"); + { + const sim = await points.simulate.grantRole([MINTER_ROLE, hook.address]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + + // ── 3. Optional parameter tuning ──────────────────────────────────────────── + if (minFee !== undefined) { + const sim = await hook.simulate.setMinFee([minFee]); + const receipt = await writeAndWait(deployer, sim); + logStep(`hook.setMinFee(${minFee})`, txUrl(pc, receipt.transactionHash)); + } + if (multiplierEnabled) { + logInfo("hook.setPriceImprovement", { maxMakerMult, maxSpread }); + await logPrompt("Proceed?"); + const sim = await hook.simulate.setPriceImprovement([maxMakerMult, maxSpread]); + const receipt = await writeAndWait(deployer, sim); + logStep("setPriceImprovement", txUrl(pc, receipt.transactionHash)); + } + + // ── 4. Grant HOOK_CALLER_ROLE to the venues ───────────────────────────────── + const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); + for (const [label, addr] of [ + ["perps", PERPS_ADDRESS], + ["futures", FUTURES_ADDRESS], + ] as const) { + if (!addr) continue; + logInfo(`hook.grantRole(HOOK_CALLER_ROLE, ${label})`, { venue: addr }); + await logPrompt("Proceed?"); + const sim = await hook.simulate.grantRole([HOOK_CALLER_ROLE, addr]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + + // ── Summary ───────────────────────────────────────────────────────────────── + logInfo("addresses", { Points: pointsAddress, PointsHook: hook.address }); + logSuccess(`New PointsHook ${hook.address} (Points ${pointsAddress})`); + logInfo("next steps", { + "1": `Upgrade perps with HOOK_ADDRESS=${hook.address} (deploys new impl + setHook)`, + "2": `Upgrade futures with HOOK_ADDRESS=${hook.address} (deploys new impl + setHook)`, + "3": "Optionally revoke MINTER_ROLE from the old hook once both venues point here", + }); + + fs.writeFileSync( + "points-hook-addr.tmp", + JSON.stringify({ points: pointsAddress, hook: hook.address }, null, 2), + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From dfa9c5a9ad26ebd029ddaf51ce810244924dc474 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 11 Jun 2026 16:11:36 +0200 Subject: [PATCH 065/180] chore(points): disable redemption indexing for initial rollout Comment out the PointsRedeemer data source and skip the swap mirror test until redemption is enabled; gitignore the deploy-points-hook address dump. --- contracts/.gitignore | 1 + .../integration/points-redemption.test.ts | 29 +++++++----- points-indexer/subgraph.template.yaml | 44 +++++++++---------- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/contracts/.gitignore b/contracts/.gitignore index a838946..387c22a 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -6,3 +6,4 @@ cache # Deploy script output (deployed addresses) points-addr.tmp +points-hook-addr.tmp diff --git a/points-indexer/integration/points-redemption.test.ts b/points-indexer/integration/points-redemption.test.ts index 4cbbfef..bea82d2 100644 --- a/points-indexer/integration/points-redemption.test.ts +++ b/points-indexer/integration/points-redemption.test.ts @@ -24,12 +24,11 @@ const ALICE_PTS = TAKER_PTS; // 1000 POINTS (6 decimals) const BOB_PTS = TAKER_PTS * 3n; // 3000 POINTS const POOL = ALICE_PTS + BOB_PTS; // 4000 GOV, 1 GOV per POINT at this ratio -describe("swap: burn debits supply, Swapped records the GOV payout split", () => { +describe.skip("swap: burn debits supply, Swapped records the GOV payout split", () => { after(() => conn.matchstick.reset()); it("debits balance + supply, records PointsRedemption, leaves totalEarned/mintCount intact", async () => { - const { contracts, accounts } = - await conn.networkHelpers.loadFixture(deployPointsStackFixture); + const { contracts, accounts } = await conn.networkHelpers.loadFixture(deployPointsStackFixture); const { points, hook, gov, redeemer } = contracts; const { owner, alice, bob, carol, venue } = accounts; @@ -39,12 +38,18 @@ describe("swap: burn debits supply, Swapped records the GOV payout split", () => await conn.matchstick.anchor(); // Accrue: carol is the maker (makerFee 0 → no maker mint); alice/bob take. - await hook.write.onFill([carol.account.address, alice.account.address, NOTIONAL, 0n, FEE, 0n, 0n], { - account: venue.account, - }); - await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE, 0n, 0n], { - account: venue.account, - }); + await hook.write.onFill( + [carol.account.address, alice.account.address, NOTIONAL, 0n, FEE, 0n, 0n], + { + account: venue.account, + }, + ); + await hook.write.onFill( + [carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE, 0n, 0n], + { + account: venue.account, + }, + ); // Wind down: finalize, fund the pool, open redemption, then alice swaps. await points.write.finalize({ account: owner.account }); @@ -65,7 +70,11 @@ describe("swap: burn debits supply, Swapped records the GOV payout split", () => const aliceUser = snap.entity("UserPoints", aliceAddr); assert.ok(aliceUser); assert.equal(String(aliceUser.total), "0", "full balance burned on swap"); - assert.equal(String(aliceUser.totalEarned), String(ALICE_PTS), "totalEarned unaffected by burn"); + assert.equal( + String(aliceUser.totalEarned), + String(ALICE_PTS), + "totalEarned unaffected by burn", + ); assert.equal(String(aliceUser.mintCount), "1", "burn is not a mint"); assert.equal(String(aliceUser.redeemedPoints), String(ALICE_PTS)); assert.equal(String(aliceUser.govReceived), String(expectedGov)); diff --git a/points-indexer/subgraph.template.yaml b/points-indexer/subgraph.template.yaml index 8caa842..9769af6 100644 --- a/points-indexer/subgraph.template.yaml +++ b/points-indexer/subgraph.template.yaml @@ -38,25 +38,25 @@ dataSources: file: ./src/points.ts # ── Redemption: POINTS → GOV swaps ──────────────────────────────────────── - - kind: ethereum - name: PointsRedeemer - network: "${NETWORK}" - source: - address: "${REDEEMER_ADDRESS}" - startBlock: ${REDEEMER_START_BLOCK} - abi: PointsRedeemer - mapping: - kind: ethereum/events - apiVersion: 0.0.9 - language: wasm/assemblyscript - entities: - - PointsProgram - - UserPoints - - PointsRedemption - abis: - - name: PointsRedeemer - file: ../contracts/abi/PointsRedeemer.json - eventHandlers: - - event: Swapped(indexed address,uint256,uint256,uint256,uint256) - handler: handleSwapped - file: ./src/points.ts + # - kind: ethereum + # name: PointsRedeemer + # network: "${NETWORK}" + # source: + # address: "${REDEEMER_ADDRESS}" + # startBlock: ${REDEEMER_START_BLOCK} + # abi: PointsRedeemer + # mapping: + # kind: ethereum/events + # apiVersion: 0.0.9 + # language: wasm/assemblyscript + # entities: + # - PointsProgram + # - UserPoints + # - PointsRedemption + # abis: + # - name: PointsRedeemer + # file: ../contracts/abi/PointsRedeemer.json + # eventHandlers: + # - event: Swapped(indexed address,uint256,uint256,uint256,uint256) + # handler: handleSwapped + # file: ./src/points.ts From e4e604ac70e8654a2c842f35a3d5040688040c2e Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:20:40 -0400 Subject: [PATCH 066/180] fix(points-subgraph): default Goldsky subgraph name to hpow-points The deploy workflow defaulted GS_NAME to "points", which would create a stray subgraph instead of updating the existing hpow-points one. Align the default with the live subgraph and the hpow-* sibling naming convention. The GOLDSKY_POINTS_SUBGRAPH_NAME override is preserved. Co-authored-by: Cursor --- .github/workflows/deploy-points-subgraph.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-points-subgraph.yml b/.github/workflows/deploy-points-subgraph.yml index 992e60e..f95dd8d 100644 --- a/.github/workflows/deploy-points-subgraph.yml +++ b/.github/workflows/deploy-points-subgraph.yml @@ -109,10 +109,10 @@ jobs: run: | ENV="${{ needs.setup.outputs.environment }}" - # Goldsky subgraph name — override via vars.GOLDSKY_POINTS_SUBGRAPH_NAME (default: points) + # Goldsky subgraph name — override via vars.GOLDSKY_POINTS_SUBGRAPH_NAME (default: hpow-points) GS_NAME="${{ vars.GOLDSKY_POINTS_SUBGRAPH_NAME }}" if [ -z "$GS_NAME" ]; then - GS_NAME="points" + GS_NAME="hpow-points" fi echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT From 1fb9984602d6dea7a91b4005e01651fcf03dcd8f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 15 Jun 2026 10:41:47 +0200 Subject: [PATCH 067/180] fix(points): disable import of reedemer handlers --- biome.json | 4 +- points-indexer/src/helpers.ts | 47 ++++++++++++++++++++ points-indexer/src/points.ts | 82 ++--------------------------------- points-indexer/src/redeem.ts | 40 +++++++++++++++++ 4 files changed, 93 insertions(+), 80 deletions(-) create mode 100644 points-indexer/src/helpers.ts create mode 100644 points-indexer/src/redeem.ts diff --git a/biome.json b/biome.json index 69803f6..a933c07 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,9 @@ { "includes": [ "indexer/src/**", - "indexer/tests/**" + "indexer/tests/**", + "points-indexer/src/**", + "points-indexer/tests/**" ], "linter": { "rules": { diff --git a/points-indexer/src/helpers.ts b/points-indexer/src/helpers.ts new file mode 100644 index 0000000..ba97e46 --- /dev/null +++ b/points-indexer/src/helpers.ts @@ -0,0 +1,47 @@ +import { Address, BigInt, Bytes } from "@graphprotocol/graph-ts"; +import { PointsProgram, UserPoints } from "../generated/schema"; + +/** + * Returns the leaderboard row, creating it on first sight and bumping the program + * user count. Caller is responsible for saving both entities. + */ +export function getOrCreateUser( + address: Address, + timestamp: BigInt, + program: PointsProgram, +): UserPoints { + let user = UserPoints.load(address); + if (!user) { + user = new UserPoints(address); + user.address = address; + user.total = BigInt.zero(); + user.totalEarned = BigInt.zero(); + user.redeemedPoints = BigInt.zero(); + user.govReceived = BigInt.zero(); + user.mintCount = 0; + user.firstSeenAt = timestamp; + user.lastActivityAt = timestamp; + program.totalUsers += 1; + } + return user; +} + +export function getOrCreateProgram(): PointsProgram { + let program = PointsProgram.load("0"); + if (!program) { + program = new PointsProgram("0"); + program.pointsToken = Bytes.empty(); + program.redeemer = Bytes.empty(); + program.totalPoints = BigInt.zero(); + program.totalMinted = BigInt.zero(); + program.totalBurned = BigInt.zero(); + program.finalized = false; + program.totalRedeemedPoints = BigInt.zero(); + program.totalGovDistributed = BigInt.zero(); + program.totalUsers = 0; + program.mintCount = 0; + program.redemptionCount = 0; + program.lastUpdatedAt = BigInt.zero(); + } + return program; +} diff --git a/points-indexer/src/points.ts b/points-indexer/src/points.ts index fa5afc9..01a81a9 100644 --- a/points-indexer/src/points.ts +++ b/points-indexer/src/points.ts @@ -1,54 +1,13 @@ -import { Address, BigInt, Bytes, dataSource } from "@graphprotocol/graph-ts"; +import { Address, Bytes, dataSource } from "@graphprotocol/graph-ts"; import { Finalized, Transfer } from "../generated/Points/Points"; -import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; -import { PointsMint, PointsProgram, PointsRedemption, UserPoints } from "../generated/schema"; import { createEventId } from "./ids"; +import { PointsMint } from "../generated/schema"; +import { getOrCreateProgram, getOrCreateUser } from "./helpers"; const ZERO_ADDRESS = Address.zero(); // ── Helpers ─────────────────────────────────────────────────────────────── -function getOrCreateProgram(): PointsProgram { - let program = PointsProgram.load("0"); - if (!program) { - program = new PointsProgram("0"); - program.pointsToken = Bytes.empty(); - program.redeemer = Bytes.empty(); - program.totalPoints = BigInt.zero(); - program.totalMinted = BigInt.zero(); - program.totalBurned = BigInt.zero(); - program.finalized = false; - program.totalRedeemedPoints = BigInt.zero(); - program.totalGovDistributed = BigInt.zero(); - program.totalUsers = 0; - program.mintCount = 0; - program.redemptionCount = 0; - program.lastUpdatedAt = BigInt.zero(); - } - return program; -} - -/** - * Returns the leaderboard row, creating it on first sight and bumping the program - * user count. Caller is responsible for saving both entities. - */ -function getOrCreateUser(address: Address, timestamp: BigInt, program: PointsProgram): UserPoints { - let user = UserPoints.load(address); - if (!user) { - user = new UserPoints(address); - user.address = address; - user.total = BigInt.zero(); - user.totalEarned = BigInt.zero(); - user.redeemedPoints = BigInt.zero(); - user.govReceived = BigInt.zero(); - user.mintCount = 0; - user.firstSeenAt = timestamp; - user.lastActivityAt = timestamp; - program.totalUsers += 1; - } - return user; -} - // ── Points token: canonical balance mirror ────────────────────────────────── // // POINTS (HP) blocks user-to-user transfers, so Transfer events are only mints @@ -106,38 +65,3 @@ export function handleFinalized(event: Finalized): void { program.lastUpdatedAt = event.block.timestamp; program.save(); } - -// ── PointsRedeemer: POINTS → GOV swaps ────────────────────────────────────── - -export function handleSwapped(event: Swapped): void { - const program = getOrCreateProgram(); - if (program.redeemer.equals(Bytes.empty())) { - program.redeemer = dataSource.address(); - } - - const pointsBurned = event.params.pointsBurned; - const govAmount = event.params.govAmount; - - const user = getOrCreateUser(event.params.user, event.block.timestamp, program); - user.redeemedPoints = user.redeemedPoints.plus(pointsBurned); - user.govReceived = user.govReceived.plus(govAmount); - user.lastActivityAt = event.block.timestamp; - user.save(); - - program.totalRedeemedPoints = program.totalRedeemedPoints.plus(pointsBurned); - program.totalGovDistributed = program.totalGovDistributed.plus(govAmount); - program.redemptionCount += 1; - program.lastUpdatedAt = event.block.timestamp; - program.save(); - - const redemption = new PointsRedemption(createEventId(event.transaction.hash, event.logIndex)); - redemption.user = user.id; - redemption.pointsBurned = pointsBurned; - redemption.govAmount = govAmount; - redemption.liquidAmount = event.params.liquidAmount; - redemption.escrowAmount = event.params.escrowAmount; - redemption.timestamp = event.block.timestamp; - redemption.blockNumber = event.block.number; - redemption.transactionHash = event.transaction.hash; - redemption.save(); -} diff --git a/points-indexer/src/redeem.ts b/points-indexer/src/redeem.ts new file mode 100644 index 0000000..2b00619 --- /dev/null +++ b/points-indexer/src/redeem.ts @@ -0,0 +1,40 @@ +import { Bytes, dataSource } from "@graphprotocol/graph-ts"; +import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; +import { PointsRedemption } from "../generated/schema"; +import { createEventId } from "./ids"; +import { getOrCreateProgram, getOrCreateUser } from "./helpers"; + +// ── PointsRedeemer: POINTS → GOV swaps ────────────────────────────────────── + +export function handleSwapped(event: Swapped): void { + const program = getOrCreateProgram(); + if (program.redeemer.equals(Bytes.empty())) { + program.redeemer = dataSource.address(); + } + + const pointsBurned = event.params.pointsBurned; + const govAmount = event.params.govAmount; + + const user = getOrCreateUser(event.params.user, event.block.timestamp, program); + user.redeemedPoints = user.redeemedPoints.plus(pointsBurned); + user.govReceived = user.govReceived.plus(govAmount); + user.lastActivityAt = event.block.timestamp; + user.save(); + + program.totalRedeemedPoints = program.totalRedeemedPoints.plus(pointsBurned); + program.totalGovDistributed = program.totalGovDistributed.plus(govAmount); + program.redemptionCount += 1; + program.lastUpdatedAt = event.block.timestamp; + program.save(); + + const redemption = new PointsRedemption(createEventId(event.transaction.hash, event.logIndex)); + redemption.user = user.id; + redemption.pointsBurned = pointsBurned; + redemption.govAmount = govAmount; + redemption.liquidAmount = event.params.liquidAmount; + redemption.escrowAmount = event.params.escrowAmount; + redemption.timestamp = event.block.timestamp; + redemption.blockNumber = event.block.number; + redemption.transactionHash = event.transaction.hash; + redemption.save(); +} From b280f5e3da98cdd2321638904f46065fa016fa96 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 15 Jun 2026 13:26:49 +0200 Subject: [PATCH 068/180] test: fix missing import --- points-indexer/tests/points.test.ts | 156 ++++++++++++++-------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/points-indexer/tests/points.test.ts b/points-indexer/tests/points.test.ts index 103e14e..fbefdf2 100644 --- a/points-indexer/tests/points.test.ts +++ b/points-indexer/tests/points.test.ts @@ -2,11 +2,11 @@ import { Address, BigInt } from "@graphprotocol/graph-ts"; import { newTypedMockEventWithParams } from "matchstick-as/assembly/defaults"; import { assert, beforeEach, clearStore, describe, test } from "matchstick-as/assembly/index"; import { Finalized, Transfer } from "../generated/Points/Points"; -import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; -import { handleFinalized, handleSwapped, handleTransfer } from "../src/points"; +// import { Swapped } from "../generated/PointsRedeemer/PointsRedeemer"; +import { handleFinalized, handleTransfer } from "../src/points"; import { POINTS_ADDRESS, - REDEEMER_ADDRESS, + // REDEEMER_ADDRESS, mockDataSource, paramAddr, paramUint, @@ -23,21 +23,21 @@ function transferEvent(from: Address, to: Address, value: BigInt): Transfer { ]); } -function swappedEvent( - user: Address, - pointsBurned: BigInt, - govAmount: BigInt, - liquidAmount: BigInt, - escrowAmount: BigInt, -): Swapped { - return newTypedMockEventWithParams([ - paramAddr("user", user), - paramUint("pointsBurned", pointsBurned), - paramUint("govAmount", govAmount), - paramUint("liquidAmount", liquidAmount), - paramUint("escrowAmount", escrowAmount), - ]); -} +// function swappedEvent( +// user: Address, +// pointsBurned: BigInt, +// govAmount: BigInt, +// liquidAmount: BigInt, +// escrowAmount: BigInt, +// ): Swapped { +// return newTypedMockEventWithParams([ +// paramAddr("user", user), +// paramUint("pointsBurned", pointsBurned), +// paramUint("govAmount", govAmount), +// paramUint("liquidAmount", liquidAmount), +// paramUint("escrowAmount", escrowAmount), +// ]); +// } describe("Points mirror (Transfer)", () => { beforeEach(() => { @@ -94,63 +94,63 @@ describe("Points mirror (Transfer)", () => { }); }); -describe("Redemption (PointsRedeemer)", () => { - beforeEach(() => { - clearStore(); - mockDataSource(REDEEMER_ADDRESS); - }); - - test("swap records redeemed points and GOV received", () => { - const alice = userAddress(1); - const evt = swappedEvent( - alice, - BigInt.fromI32(1_000_000), - BigInt.fromI32(2_000_000), - BigInt.fromI32(1_000_000), - BigInt.fromI32(1_000_000), - ); - handleSwapped(evt); - - assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "1000000"); - assert.fieldEquals("UserPoints", alice.toHexString(), "govReceived", "2000000"); - assert.fieldEquals("PointsProgram", "0", "totalRedeemedPoints", "1000000"); - assert.fieldEquals("PointsProgram", "0", "totalGovDistributed", "2000000"); - assert.fieldEquals("PointsProgram", "0", "redemptionCount", "1"); - - const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); - assert.fieldEquals("PointsRedemption", id, "pointsBurned", "1000000"); - assert.fieldEquals("PointsRedemption", id, "escrowAmount", "1000000"); - }); -}); - -describe("End-to-end reconciliation", () => { - beforeEach(() => { - clearStore(); - }); - - test("mint then redeem reconciles balance, earned, and circulating supply", () => { - const alice = userAddress(1); - - mockDataSource(POINTS_ADDRESS); - handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_500_000))); - // Redemption burns part of the balance via the token's Transfer(to == 0x0). - handleTransfer(transferEvent(alice, ZERO, BigInt.fromI32(500_000))); - - mockDataSource(REDEEMER_ADDRESS); - handleSwapped( - swappedEvent( - alice, - BigInt.fromI32(500_000), - BigInt.fromI32(1_000_000), - BigInt.fromI32(500_000), - BigInt.fromI32(500_000), - ), - ); - - assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1000000"); - assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1500000"); - assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "500000"); - assert.fieldEquals("PointsProgram", "0", "totalPoints", "1000000"); - assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); - }); -}); +// describe("Redemption (PointsRedeemer)", () => { +// beforeEach(() => { +// clearStore(); +// mockDataSource(REDEEMER_ADDRESS); +// }); + +// test("swap records redeemed points and GOV received", () => { +// const alice = userAddress(1); +// const evt = swappedEvent( +// alice, +// BigInt.fromI32(1_000_000), +// BigInt.fromI32(2_000_000), +// BigInt.fromI32(1_000_000), +// BigInt.fromI32(1_000_000), +// ); +// handleSwapped(evt); + +// assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "1000000"); +// assert.fieldEquals("UserPoints", alice.toHexString(), "govReceived", "2000000"); +// assert.fieldEquals("PointsProgram", "0", "totalRedeemedPoints", "1000000"); +// assert.fieldEquals("PointsProgram", "0", "totalGovDistributed", "2000000"); +// assert.fieldEquals("PointsProgram", "0", "redemptionCount", "1"); + +// const id = evt.transaction.hash.concatI32(evt.logIndex.toI32()).toHexString(); +// assert.fieldEquals("PointsRedemption", id, "pointsBurned", "1000000"); +// assert.fieldEquals("PointsRedemption", id, "escrowAmount", "1000000"); +// }); +// }); + +// describe("End-to-end reconciliation", () => { +// beforeEach(() => { +// clearStore(); +// }); + +// test("mint then redeem reconciles balance, earned, and circulating supply", () => { +// const alice = userAddress(1); + +// mockDataSource(POINTS_ADDRESS); +// handleTransfer(transferEvent(ZERO, alice, BigInt.fromI32(1_500_000))); +// // Redemption burns part of the balance via the token's Transfer(to == 0x0). +// handleTransfer(transferEvent(alice, ZERO, BigInt.fromI32(500_000))); + +// mockDataSource(REDEEMER_ADDRESS); +// handleSwapped( +// swappedEvent( +// alice, +// BigInt.fromI32(500_000), +// BigInt.fromI32(1_000_000), +// BigInt.fromI32(500_000), +// BigInt.fromI32(500_000), +// ), +// ); + +// assert.fieldEquals("UserPoints", alice.toHexString(), "total", "1000000"); +// assert.fieldEquals("UserPoints", alice.toHexString(), "totalEarned", "1500000"); +// assert.fieldEquals("UserPoints", alice.toHexString(), "redeemedPoints", "500000"); +// assert.fieldEquals("PointsProgram", "0", "totalPoints", "1000000"); +// assert.fieldEquals("PointsProgram", "0", "totalUsers", "1"); +// }); +// }); From 49e48e96f220b93340ae8366b86a44af8f728b2c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 16 Jun 2026 14:01:10 +0200 Subject: [PATCH 069/180] ci(keeper): verify deploy rolled forward to the new revision The verify job only ran `aws ecs wait services-stable`, which reports success even when a task crash-loops on startup and the deployment circuit breaker rolls the service back to the prior revision. A failed deploy looked green (tag pushed, success notification) while the old revision kept serving. Harden verification to assert the new artifact is actually live: - deploy job exports the registered task-def ARN; keeper stamps its build version (KEEPER_VERSION) onto `/health`. - verify asserts the PRIMARY deployment's taskDefinition == the ARN we deployed, rolloutState == COMPLETED, and runningCount == desiredCount. - smoke-tests `/health` for status=ok AND the expected version. - on failure, dumps stopped-task stop reasons + recent CloudWatch logs. --- .github/workflows/deploy-keeper.yml | 142 +++++++++++++++++++++-- keeper/src/config.ts | 8 ++ keeper/src/runtime/healthcheck.ts | 1 + keeper/tests/integration/buildKeeper.ts | 1 + keeper/tests/runtime/healthcheck.test.ts | 2 + 5 files changed, 147 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index e94af19..d3c6138 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -182,6 +182,9 @@ jobs: needs: build if: needs.build.outputs.is_cicd_branch != 'true' environment: ${{ needs.build.outputs.environment }} + outputs: + task_def_arn: ${{ steps.deploy.outputs.task_def_arn }} + skipped: ${{ steps.svc_check.outputs.skip }} steps: - name: Configure AWS credentials @@ -215,6 +218,7 @@ jobs: fi - name: Render new task definition + deploy + id: deploy if: steps.svc_check.outputs.skip != 'true' env: CLUSTER: ${{ needs.build.outputs.ecs_cluster }} @@ -222,6 +226,7 @@ jobs: TASK_FAMILY: ${{ needs.build.outputs.task_family }} REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} + KEEPER_VERSION: ${{ needs.build.outputs.version }} NETWORK: ${{ vars.NETWORK }} VAULT_ADDRESS: ${{ vars.VAULT_ADDRESS }} @@ -292,7 +297,9 @@ jobs: --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ --arg LIQUIDATOR_PRIVATE_KEY "${LIQUIDATOR_PRIVATE_KEY}" \ --arg WEBHOOK_SECRET "${WEBHOOK_SECRET:-}" \ + --arg KEEPER_VERSION "${KEEPER_VERSION}" \ '[ + {name:"KEEPER_VERSION", value:$KEEPER_VERSION}, {name:"NETWORK", value:$NETWORK}, {name:"VAULT_ADDRESS", value:$VAULT_ADDRESS}, {name:"PME_ADDRESS", value:$PME_ADDRESS}, @@ -328,6 +335,12 @@ jobs: echo "✅ Registered: ${NEW_TASK_DEF}" + # Export the exact ARN we deployed so the verify job can assert the + # service actually rolled forward to *this* revision (and didn't + # silently roll back to a prior one via the deployment circuit + # breaker — which still leaves the service "stable"). + echo "task_def_arn=${NEW_TASK_DEF}" >> "$GITHUB_OUTPUT" + aws ecs update-service \ --cluster "${CLUSTER}" \ --service "${SERVICE}" \ @@ -352,12 +365,22 @@ jobs: aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarKeeper-Verify-${{ github.run_id }} - - name: Wait for service to stabilize + - name: Verify rollout reached the deployed revision + id: rollout env: SERVICE: ${{ needs.build.outputs.ecs_service }} CLUSTER: ${{ needs.build.outputs.ecs_cluster }} REGION: ${{ needs.build.outputs.aws_region }} + EXPECTED_ARN: ${{ needs.deploy.outputs.task_def_arn }} + DEPLOY_SKIPPED: ${{ needs.deploy.outputs.skipped }} run: | + set -euo pipefail + + if [ "${DEPLOY_SKIPPED}" = "true" ] || [ -z "${EXPECTED_ARN}" ]; then + echo "⚠️ Deploy was skipped (no task definition registered) — nothing to verify." + exit 0 + fi + ACTIVE=$(aws ecs describe-services \ --cluster "$CLUSTER" \ --services "$SERVICE" \ @@ -369,12 +392,117 @@ jobs: exit 0 fi - echo "⏳ Waiting for $SERVICE to stabilize..." - aws ecs wait services-stable \ - --cluster "$CLUSTER" \ - --services "$SERVICE" \ - --region "$REGION" - echo "✅ Stable" + echo "diagnose=true" >> "$GITHUB_OUTPUT" + echo "🎯 Expecting service to converge on: ${EXPECTED_ARN}" + + # `wait services-stable` only proves the service is steady — NOT that + # it's steady on the *new* revision. With the deployment circuit + # breaker enabled, a task that crash-loops on startup gets rolled + # back to the previous revision, and the service is then "stable" + # again. So we wait (best-effort) and then assert the truth below. + echo "⏳ Waiting for $SERVICE to stabilize (best-effort)..." + if aws ecs wait services-stable \ + --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION"; then + echo " service reported stable" + else + echo " ⚠️ stabilization wait did not succeed — inspecting state anyway" + fi + + # The PRIMARY deployment is the one ECS is currently driving toward. + # rolloutState is COMPLETED only when the new tasks passed health + # checks; FAILED means the circuit breaker tripped (and may have + # rolled back). We assert BOTH the task-def ARN and the rollout state. + PRIMARY=$(aws ecs describe-services \ + --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION" \ + --query 'services[0].deployments[?status==`PRIMARY`] | [0]' \ + --output json) + + LIVE_ARN=$(echo "$PRIMARY" | jq -r '.taskDefinition // ""') + ROLLOUT=$(echo "$PRIMARY" | jq -r '.rolloutState // "UNKNOWN"') + ROLLOUT_REASON=$(echo "$PRIMARY" | jq -r '.rolloutStateReason // ""') + RUNNING=$(echo "$PRIMARY" | jq -r '.runningCount // 0') + DESIRED=$(echo "$PRIMARY" | jq -r '.desiredCount // 0') + FAILED=$(echo "$PRIMARY" | jq -r '.failedTasks // 0') + + echo " live PRIMARY task-def : ${LIVE_ARN}" + echo " rolloutState : ${ROLLOUT} (${ROLLOUT_REASON})" + echo " running/desired/failed: ${RUNNING}/${DESIRED}/${FAILED}" + + FAIL=0 + if [ "$LIVE_ARN" != "$EXPECTED_ARN" ]; then + echo "::error::Service is running '${LIVE_ARN}' but we deployed '${EXPECTED_ARN}'. The new revision did not roll out (likely a startup crash + circuit-breaker rollback)." + FAIL=1 + fi + if [ "$ROLLOUT" != "COMPLETED" ]; then + echo "::error::Deployment rolloutState is '${ROLLOUT}', expected 'COMPLETED'. Reason: ${ROLLOUT_REASON}" + FAIL=1 + fi + if [ "$RUNNING" != "$DESIRED" ]; then + echo "::error::runningCount (${RUNNING}) != desiredCount (${DESIRED})." + FAIL=1 + fi + + if [ "$FAIL" != "0" ]; then + exit 1 + fi + echo "✅ Service converged on the deployed revision." + + - name: Smoke-test /health reports the deployed version + if: steps.rollout.outputs.diagnose == 'true' + env: + HEALTH_URL: ${{ needs.build.outputs.keeper_health_url }} + EXPECTED_VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + echo "🔎 GET ${HEALTH_URL} (expecting version=${EXPECTED_VERSION})" + + # Give the new task a moment to bind the health port, then poll. + ATTEMPTS=12 + for i in $(seq 1 "$ATTEMPTS"); do + BODY=$(curl -fsS -m 10 "$HEALTH_URL" 2>/dev/null || true) + if [ -n "$BODY" ]; then + STATUS=$(echo "$BODY" | jq -r '.status // "?"') + LIVE_VERSION=$(echo "$BODY" | jq -r '.info.version // "?"') + echo " attempt ${i}: status=${STATUS} version=${LIVE_VERSION}" + if [ "$STATUS" = "ok" ] && [ "$LIVE_VERSION" = "$EXPECTED_VERSION" ]; then + echo "✅ /health is OK and serving the deployed version." + exit 0 + fi + else + echo " attempt ${i}: no response yet" + fi + sleep 10 + done + + echo "::error::/health never reported status=ok with version=${EXPECTED_VERSION}. Last body: ${BODY:-}" + exit 1 + + - name: Diagnose failed rollout + if: failure() && steps.rollout.outputs.diagnose == 'true' + env: + SERVICE: ${{ needs.build.outputs.ecs_service }} + CLUSTER: ${{ needs.build.outputs.ecs_cluster }} + REGION: ${{ needs.build.outputs.aws_region }} + LOG_GROUP: /ecs/col-mar-keeper-${{ needs.build.outputs.env_suffix }} + run: | + set +e + echo "## ❌ Rollout diagnostics" >> "$GITHUB_STEP_SUMMARY" + + echo "### Stopped tasks (stop reasons)" + STOPPED=$(aws ecs list-tasks --cluster "$CLUSTER" --service-name "$SERVICE" \ + --desired-status STOPPED --region "$REGION" \ + --query 'taskArns' --output text) + if [ -n "$STOPPED" ] && [ "$STOPPED" != "None" ]; then + aws ecs describe-tasks --cluster "$CLUSTER" --tasks $STOPPED --region "$REGION" \ + --query 'tasks[].{stoppedReason:stoppedReason,task:taskArn,containers:containers[].{name:name,exitCode:exitCode,reason:reason}}' \ + --output json | tee -a "$GITHUB_STEP_SUMMARY" + else + echo "(no stopped tasks found)" | tee -a "$GITHUB_STEP_SUMMARY" + fi + + echo "### Recent keeper logs (${LOG_GROUP}, last 10m)" + aws logs tail "$LOG_GROUP" --region "$REGION" --since 10m --format short 2>&1 \ + | tail -n 60 | tee -a "$GITHUB_STEP_SUMMARY" cleanup: name: 🧹 Cleanup diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 22f6acd..6ef761f 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -30,6 +30,13 @@ export const SUPPORTED_NETWORKS: readonly NetworkName[] = [ ] as const; export interface Config { + /** + * Build identity stamp (image tag / git describe), surfaced on `/health` + * so a deploy pipeline can assert the *new* artifact is actually serving + * traffic rather than an old revision a circuit-breaker rolled back to. + * Defaults to `"dev"` for local runs where `KEEPER_VERSION` is unset. + */ + version: string; chain: { /** Logical network selector. Drives both `rpcUrl` and the viem chain object. */ network: NetworkName; @@ -344,6 +351,7 @@ export function loadConfig(): Config { const network = requireNetwork(); return { + version: process.env.KEEPER_VERSION ?? "dev", chain: { network, rpcUrl: resolveRpcUrl(network), diff --git a/keeper/src/runtime/healthcheck.ts b/keeper/src/runtime/healthcheck.ts index 0f602af..b3b0fab 100644 --- a/keeper/src/runtime/healthcheck.ts +++ b/keeper/src/runtime/healthcheck.ts @@ -68,6 +68,7 @@ export class Healthcheck { */ info(): Record { return { + version: this.config.version, network: this.config.chain.network, discoveryMode: this.config.chain.discoveryMode, dryRun: String(this.config.keeper.dryRun), diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 9561b62..52503fe 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -209,6 +209,7 @@ function buildConfig( overrides: BuildKeeperOverrides, ): Config { return { + version: "test", chain: { network: "hardhat", rpcUrl: stack.rpcUrl, diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index 8d317b6..3bdb74a 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -18,6 +18,7 @@ const silentLogger = pino({ level: "silent" }); const SIGNER: Address = "0x000000000000000000000000000000000000005C"; const STUB_CONFIG: Config = { + version: "test", chain: { network: "hardhat", rpcUrl: "http://stub", @@ -212,6 +213,7 @@ describe("runtime/healthcheck: info", () => { }); const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); assert.deepEqual(hc.info(), { + version: "test", network: "hardhat", discoveryMode: "events", dryRun: "false", From a7f89b8fd167655d1157568230bc6779ce37d512 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 16 Jun 2026 14:17:56 +0200 Subject: [PATCH 070/180] test(keeper): de-flake delivery settlement index-drop assertion The "settles a futures position at its delivery date" integration test asserted `delivery.has(id) === false` synchronously right after the manual `sweep()`. The coordinator also runs a background safety-net sweep every `sweepIntervalMs`; when it wins the race the on-chain `LotClosed` is observable a tick before the in-memory index is pruned, so the synchronous check intermittently failed (actual true, expected false). Poll for the index drop with `waitFor` instead, matching how the rest of the suite handles async settling. --- .../tests/integration/keeper.integration.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 5cc702e..5b09a9a 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -671,6 +671,20 @@ describe("DeliveryCoordinator (live RPC)", () => { // a `LotClosed` event from the keeper's signer, and the // index dropped all of them. await expectFuturesClosed(ctx, alice); + + // The index drop happens after the settling tx confirms. The + // coordinator also runs a background safety-net sweep every + // `sweepIntervalMs`; when it wins the race against this manual + // `sweep()` the on-chain `LotClosed` can be observable a tick before + // the in-memory index is pruned. Poll for the drop rather than + // asserting it synchronously to avoid that race. + const delivery = keeper.delivery; + assert.ok(delivery); + await waitFor( + () => positionsBefore.every((id) => !delivery.has(id)), + 10_000, + ); + const settledBlocks: bigint[] = []; for (const id of positionsBefore) { const settledBlock = await readLotClosedBlock(ctx, id); @@ -679,7 +693,6 @@ describe("DeliveryCoordinator (live RPC)", () => { `expected a LotClosed event for position ${id}`, ); settledBlocks.push(settledBlock); - assert.equal(keeper.delivery.has(id), false, `settled position ${id} is dropped`); } // Batching invariant: all 12 settlements ride a single // `Futures.multicall(bytes[])` transaction, so every From 094efc8c0f08cfba920520e64b3359a7ac73b9ed Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 19 Jun 2026 19:21:05 +0200 Subject: [PATCH 071/180] feat: disable delivery --- keeper/package.json | 4 +- keeper/pnpm-lock.yaml | 11 +- keeper/pnpm-workspace.yaml | 1 - keeper/src/config.ts | 32 +- keeper/src/delivery/coordinator.ts | 273 ++++-------------- keeper/src/index.ts | 7 +- keeper/tests/delivery/coordinator.test.ts | 180 ++---------- keeper/tests/integration/buildKeeper.ts | 17 +- keeper/tests/integration/deployStack.ts | 9 - keeper/tests/integration/helpers.ts | 2 +- .../integration/keeper.integration.test.ts | 71 +++-- keeper/tests/runtime/healthcheck.test.ts | 1 - 12 files changed, 155 insertions(+), 453 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 62011cd..a68b4a6 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/no-delivery&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, @@ -32,4 +32,4 @@ "pino-pretty": "^13.1.3" }, "packageManager": "pnpm@11.1.1" -} +} \ No newline at end of file diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 14d1847..80754fc 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -7,7 +7,6 @@ settings: patchedDependencies: collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d - futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a importers: @@ -23,8 +22,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: link:../../futures-marketplace/contracts/abi + version: link:../../futures-marketplace/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,10 +165,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/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c} - version: 0.0.0 - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -365,8 +360,6 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} - help-me@5.0.0: {} isows@1.0.7(ws@8.20.1): diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index 8b68120..0adc312 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -7,4 +7,3 @@ blockExoticSubdeps: false patchedDependencies: collateral-margin-abi: patches/collateral-margin-abi@.patch derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch - futures-marketplace-abi: patches/futures-marketplace-abi@.patch diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 6ef761f..eaaa3e3 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -179,25 +179,18 @@ export interface Config { }; delivery: { /** - * Opt-in: when true, the keeper acts as the futures `validator` and calls - * `closeDelivery(positionId, blameSeller)` on every active futures position - * the moment its `deliveryAt` is reached. Defaults to `false` so a stock - * keeper deployment doesn't accidentally start cash-settling positions on a - * chain where it isn't the configured validator. + * Opt-in: when true, the keeper permissionlessly calls + * `settlePosition(positionId)` on every active futures position the moment + * its `deliveryAt` (maturity) is reached, cash-settling it at the oracle + * mark. Defaults to `false` so a stock keeper deployment doesn't start + * settling positions unless explicitly enabled. * - * Requires the keeper signer (`LIQUIDATOR_PRIVATE_KEY`) to equal the - * Futures contract's `validatorAddress` — otherwise `closeDelivery` reverts - * `OnlyValidatorOrPositionParticipant` and the module logs the skip. + * `settlePosition` is permissionless — the keeper signer + * (`LIQUIDATOR_PRIVATE_KEY`) needs no special role, only enough gas. (The + * retired `closeDelivery` path required the signer to equal the Futures + * contract's `validatorAddress`.) */ enabled: boolean; - /** - * Side blamed for the breach when settling at delivery start. The breach - * penalty is paid by the blamed party to the counterparty; with - * `breachPenaltyRatePerDay = 0` (the default in production) the choice is - * cosmetic. Set `true` to blame the seller (default: they're the ones - * who didn't deliver hashrate), `false` to blame the buyer. - */ - blameSeller: boolean; /** * Cadence of the periodic safety-net sweep over tracked positions. Picks * up anything the per-position timers missed (process restarts, missed @@ -205,7 +198,7 @@ export interface Config { */ sweepIntervalMs: number; /** - * Delay after `position.deliveryAt` before attempting `closeDelivery`. + * Delay after `position.deliveryAt` before attempting `settlePosition`. * Adds a small cushion so the on-chain `block.timestamp >= deliveryAt` * guard is satisfied even when local and miner clocks drift slightly. */ @@ -220,11 +213,11 @@ export interface Config { */ bootstrapUsers: readonly Address[]; /** - * Maximum number of `closeDelivery` calls bundled into a single + * Maximum number of `settlePosition` calls bundled into a single * `Futures.multicall(bytes[])` 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 `closeDelivery` is roughly 200-300k gas, so 50 is + * block gas, each `settlePosition` is roughly 200-300k gas, so 50 is * conservative (~15M gas worst case). Set lower if your participants * have unusually expensive settlement paths. */ @@ -414,7 +407,6 @@ export function loadConfig(): Config { }, delivery: { enabled: process.env.DELIVERY_KEEPER_ENABLED === "true", - blameSeller: process.env.DELIVERY_BLAME_SELLER !== "false", sweepIntervalMs: Number(process.env.DELIVERY_SWEEP_INTERVAL_MS ?? "60000"), settleDelayMs: Number(process.env.DELIVERY_SETTLE_DELAY_MS ?? "5000"), bootstrapUsers: parseAddressList("DELIVERY_BOOTSTRAP_USERS"), diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index fdf8aa6..32af590 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -16,20 +16,22 @@ import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import { formatGasCost } from "../tx/gasCost.ts"; /** - * Optional keeper module that calls `Futures.closeDelivery(positionId, blameSeller)` - * on every active futures position the moment its `deliveryAt` is reached. - * Settlement happens at the *current* market price for the full delivery - * window (positionElapsedTime = 0 → the entire position cash-settles at - * `getMarketPrice()`), avoiding the need for any physical hashrate delivery. + * Optional keeper module that calls `Futures.settlePosition(positionId)` on + * every active futures position the moment its `deliveryAt` (maturity) is + * reached. Settlement marks the entire position to the expiration's pinned + * settlement price (recorded once per `deliveryAt` from the oracle; the first + * settle lazily pins it) and cash-settles PnL through the insurance fund — + * there is no physical hashrate delivery, escrow, breach penalty, or validator + * involvement. Pinning the price per expiration makes settlement deterministic: + * every position at a `deliveryAt` settles at the same price no matter when its + * tx lands. * - * Authorization: `closeDelivery` is gated by either - * 1. `_msgSender() == validatorAddress` (this module's path), or - * 2. `_msgSender() == position.{buyer,seller}` - * - * The keeper's signer must therefore equal the Futures contract's - * `validatorAddress` for this module to do anything. If it doesn't, every - * settlement attempt simulates as `OnlyValidatorOrPositionParticipant` and - * the module logs the skip without crashing — useful in dev / dry-run setups. + * Authorization: `settlePosition` is permissionless — any address may settle + * any matured position. The keeper needs no special role; its signer only has + * to hold enough gas to broadcast. (Contrast the retired validator-gated + * `closeDelivery`, which required `_msgSender() == validatorAddress` or a + * position participant.) Because settlement has no upper time bound, a matured + * position can always be settled later — there are no permanently stuck lots. * * Hot path is event-driven: * @@ -66,7 +68,7 @@ import { formatGasCost } from "../tx/gasCost.ts"; * * Single source of truth for "is this position alive": the contract emits * `LotClosed` at the end of every `_removePosition`, including the - * cash-settlement path inside `closeDelivery` itself. The module never has + * cash-settlement path inside `settlePosition` itself. The module never has * to track its own settled-set across restarts — once settled, the contract * removes the position and `getPositionById(id).seller == 0` permanently. */ @@ -77,14 +79,6 @@ export class DeliveryCoordinator { private readonly timers = new Map(); /** Set of positions with an in-flight `settle()` — coalesces duplicate triggers. */ private readonly inflight = new Set(); - /** - * Per-revert "we've already warned about this once" set so persistent - * operational misconfigs (wrong validator key, missed delivery window) - * surface loudly on first hit but don't flood the log on every sweep. - * Keyed by `:` so each position warns once per type - * per process — clears nothing across restarts, which is what we want. - */ - private readonly warned = new Set(); /** * Serialized broadcast chain: every `attemptSettle` awaits the previous * one before sending its own tx. The keeper has a single signer, so two @@ -97,8 +91,6 @@ export class DeliveryCoordinator { /** Disposers returned by `watchContractEvent`. */ private unwatchers: Array<() => void> = []; private sweepTimer: NodeJS.Timeout | undefined; - /** Cached `deliveryDurationDays` (read once at start). */ - private deliveryDurationSeconds: bigint | undefined; private running = false; private readonly chain: Chain; @@ -117,7 +109,7 @@ export class DeliveryCoordinator { this.logger = logger.child({ component: "deliveryCoordinator" }); // Optional — see FuturesVenue for the rationale. Used only to enrich // the two confirmed-tx logs (batched `multicall` and single - // `closeDelivery`) with a `gasCostUsd` field. + // `settlePosition`) with a `gasCostUsd` field. this.ethUsdFeed = ethUsdFeed; } @@ -133,35 +125,11 @@ export class DeliveryCoordinator { if (this.running) return; this.running = true; - const days = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - })) as number; - this.deliveryDurationSeconds = BigInt(days) * 86_400n; this.logger.info( - { - deliveryDurationDays: days, - blameSeller: this.config.delivery.blameSeller, - }, - "delivery coordinator starting", + { signer: this.chain.account.address }, + "delivery coordinator starting (permissionless settlePosition)", ); - // Pre-flight: verify the keeper signer is actually authorised to call - // `closeDelivery`. If not, every settle attempt will silently revert - // `OnlyValidatorOrPositionParticipant` inside simulate, and the only - // operator-visible signal is "no settlements happen" — easy to miss - // until a user reports a stuck position. We fail fast instead: throw, - // bubble up to `main().catch` → `process.exit(1)`. The orchestrator - // (k8s, systemd, docker restart-policy) sees the crash, cycles the - // pod, and standard infra alerting (CrashLoopBackOff, healthcheck - // 503, sentry on-error) pages on-call without any keeper-specific - // notification plumbing. A code restart is not required to recover — - // just rotate `LIQUIDATOR_PRIVATE_KEY` to match - // `Futures.validatorAddress()` (or unset `DELIVERY_KEEPER_ENABLED`) - // and the next pod will start cleanly. - await this.assertValidatorAuthorised(); - this.unwatchers.push( this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, @@ -207,44 +175,6 @@ export class DeliveryCoordinator { this.unwatchers = []; } - /** - * Reads `Futures.validatorAddress()` and compares it to the keeper's - * signer. Throws when they don't match — caller (`start()`) propagates - * the throw up to `main().catch` so the process exits non-zero. - * - * The check is mandatory because the alternative (silent skip on every - * `closeDelivery` revert) is invisible to operators at the default - * `info` log level. A crash makes the misconfiguration impossible to - * miss: the orchestrator restart loop and healthcheck 503 are the - * existing operator-alert path; we don't need a parallel notification - * channel just for delivery. - */ - private async assertValidatorAuthorised(): Promise { - const validator = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "validatorAddress", - })) as Address; - const signer = this.chain.account.address; - if (validator.toLowerCase() === signer.toLowerCase()) { - this.logger.info( - { signer, validator, futures: this.config.futures.address }, - "delivery: validator alignment OK", - ); - return; - } - const message = - "DELIVERY_KEEPER_ENABLED=true but the keeper signer is not the futures validator. " + - `Futures.validatorAddress()=${validator} but LIQUIDATOR_PRIVATE_KEY → ${signer}. ` + - "Either rotate LIQUIDATOR_PRIVATE_KEY to match the validator, or unset " + - "DELIVERY_KEEPER_ENABLED. Refusing to start so this is impossible to miss."; - this.logger.error( - { signer, validator, futures: this.config.futures.address }, - message, - ); - throw new Error(message); - } - /** * Replay `LotCreated` and `LotClosed` in `[fromBlock, head]` so * the in-memory index reflects every position the contract still considers @@ -521,45 +451,26 @@ export class DeliveryCoordinator { } /** - * Scan all tracked positions; settle any whose `deliveryAt` is past and - * whose settlement window has not yet expired. Skips positions with an - * in-flight settle to avoid duplicate sends. Public for tests. + * Scan all tracked positions; settle any whose `deliveryAt` (maturity) is + * past. Skips positions with an in-flight settle to avoid duplicate sends. + * Public for tests. * - * Uses the chain's latest `block.timestamp` rather than `Date.now()` so - * the sweep agrees with the contract's `_msgSender == validator` window - * checks (`block.timestamp >= deliveryAt`, `block.timestamp <= deliveryAt - * + duration`). On hardhat with `evm_setNextBlockTimestamp`, chain time - * and wall-clock can diverge by years; in production they're within - * one block of each other so this read is essentially free. + * Uses the chain's latest `block.timestamp` rather than `Date.now()` so the + * sweep agrees with the contract's maturity check (`block.timestamp >= + * deliveryAt`). `settlePosition` has no upper time bound, so there is no + * "window expired" case — a matured position stays settleable indefinitely. + * On hardhat with `evm_setNextBlockTimestamp`, chain time and wall-clock can + * diverge by years; in production they're within one block of each other so + * this read is essentially free. */ async sweep(): Promise { const latestBlock = await this.chain.publicClient.getBlock(); const nowSec = latestBlock.timestamp; const candidates: TrackedPosition[] = []; - const window = this.deliveryDurationSeconds ?? 0n; for (const pos of this.tracked.values()) { if (this.inflight.has(pos.positionId)) continue; if (nowSec < pos.deliveryAt) continue; - // After `deliveryAt + duration` the contract reverts `PositionDeliveryExpired`. - // Skip — there's no entry point that can settle the position any more. - if (window > 0n && nowSec > pos.deliveryAt + window) { - this.logger.warn( - { - positionId: pos.positionId, - deliveryAt: pos.deliveryAt.toString(), - now: nowSec.toString(), - }, - "delivery: settlement window expired — position abandoned", - ); - this.tracked.delete(pos.positionId); - const t = this.timers.get(pos.positionId); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(pos.positionId); - } - continue; - } candidates.push(pos); } @@ -624,19 +535,16 @@ export class DeliveryCoordinator { } /** - * Bundles up to `maxBatchSize` `closeDelivery` calls into a single + * Bundles up to `maxBatchSize` `settlePosition` calls into a single * `Futures.multicall(bytes[])` transaction. OZ `MulticallUpgradeable` - * uses `delegatecall` per entry, so `msg.sender` is preserved and the - * contract's `_msgSender == validator || _msgSender == participant` - * auth check is satisfied identically to a direct call. + * uses `delegatecall` per entry, so `msg.sender` is preserved — though + * `settlePosition` is permissionless, so no auth depends on the sender. * * Two-phase to keep one bad apple from spoiling the batch: * 1. Per-id `simulateContract` in parallel — drops candidates that - * would revert (already-settled, expired window, oracle stale, etc). + * would revert (already-settled, not yet matured, oracle stale, etc). * Each revert is reported through the same severity taxonomy as - * individual settles, so an operator-actionable revert - * (`OnlyValidatorOrPositionParticipant`) still surfaces at error - * level even when discovered as part of a batch. + * individual settles. * 2. One `multicall` write tx for the survivors. If the *write* * reverts (rare — simulate-then-write race), we fall back to * per-id `attemptSettle` so a single newly-poisoned id can't @@ -670,8 +578,6 @@ export class DeliveryCoordinator { * per-id retries if the batch tx itself fails. */ private async attemptBatch(positionIds: readonly Hex[]): Promise { - const blameSeller = this.config.delivery.blameSeller; - type SimParams = Parameters< typeof this.chain.publicClient.simulateContract >[0]; @@ -680,8 +586,8 @@ export class DeliveryCoordinator { this.chain.publicClient.simulateContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "closeDelivery", - args: [id, blameSeller], + functionName: "settlePosition", + args: [id], account: this.chain.account, } as unknown as SimParams), ), @@ -697,11 +603,8 @@ export class DeliveryCoordinator { } const decoded = decodeRecoverableRevert(r.reason); if (decoded !== undefined) { - this.logRecoverableRevert(decoded, id, blameSeller); - if ( - decoded === "PositionNotExists" || - decoded === "PositionDeliveryExpired" - ) { + this.logRecoverableRevert(decoded, id); + if (decoded === "PositionNotExists") { this.tracked.delete(id); const t = this.timers.get(id); if (t !== undefined) { @@ -729,13 +632,13 @@ export class DeliveryCoordinator { if (this.config.keeper.dryRun) { this.logger.info( { batchSize: settleable.length }, - "[dryRun] would call Futures.multicall(closeDelivery × N)", + "[dryRun] would call Futures.multicall(settlePosition × N)", ); for (const id of settleable) this.tracked.delete(id); return; } - // Encode each closeDelivery into bytes for OZ multicall(bytes[]). + // Encode each settlePosition into bytes for OZ multicall(bytes[]). // Encoding can only fail on a malformed positionId (e.g. wrong // bytes32 width from a corrupted RPC read). We isolate that // per-position rather than letting one bad id swallow the whole @@ -746,8 +649,8 @@ export class DeliveryCoordinator { try { const data = encodeFunctionData({ abi: FuturesAbi, - functionName: "closeDelivery", - args: [id, blameSeller], + functionName: "settlePosition", + args: [id], }); calldatas.push(data); encodableIds.push(id); @@ -840,8 +743,7 @@ export class DeliveryCoordinator { } private async attemptSettle(positionId: Hex): Promise { - const blameSeller = this.config.delivery.blameSeller; - const args = [positionId, blameSeller] as const; + const args = [positionId] as const; type SimParams = Parameters< typeof this.chain.publicClient.simulateContract @@ -854,7 +756,7 @@ export class DeliveryCoordinator { const sim = (await this.chain.publicClient.simulateContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "closeDelivery", + functionName: "settlePosition", args, account: this.chain.account, } as unknown as SimParams)) as SimReturn; @@ -862,13 +764,10 @@ export class DeliveryCoordinator { } catch (err) { const decoded = decodeRecoverableRevert(err); if (decoded !== undefined) { - this.logRecoverableRevert(decoded, positionId, blameSeller); - // PositionNotExists / PositionDeliveryExpired → contract no longer - // accepts settlement. Drop from the index so we don't keep retrying. - if ( - decoded === "PositionNotExists" || - decoded === "PositionDeliveryExpired" - ) { + this.logRecoverableRevert(decoded, positionId); + // PositionNotExists → contract no longer accepts settlement (already + // settled). Drop from the index so we don't keep retrying. + if (decoded === "PositionNotExists") { this.tracked.delete(positionId); const t = this.timers.get(positionId); if (t !== undefined) { @@ -882,10 +781,7 @@ export class DeliveryCoordinator { } if (this.config.keeper.dryRun) { - this.logger.info( - { positionId, blameSeller }, - "[dryRun] would call closeDelivery", - ); + this.logger.info({ positionId }, "[dryRun] would call settlePosition"); this.tracked.delete(positionId); return; } @@ -903,12 +799,11 @@ export class DeliveryCoordinator { this.logger.info( { positionId, - blameSeller, hash, blockNumber: receipt.blockNumber.toString(), ...formatGasCost(receipt, this.ethUsdFeed), }, - "delivery: closeDelivery confirmed", + "delivery: settlePosition confirmed", ); this.tracked.delete(positionId); const t = this.timers.get(positionId); @@ -990,68 +885,30 @@ export class DeliveryCoordinator { } /** - * Differentiated logging for the recoverable-revert taxonomy. Three buckets: + * Differentiated logging for the recoverable-revert taxonomy. Two buckets: * - * debug — transient, will retry on the next sweep with no operator - * action needed (pre-window, oracle stale, oracle invalid). * info — terminal but benign: the contract no longer accepts settlement - * because someone else already did it. We drop and move on. - * error — operational red flag that should page. Deduped per - * `(revert, positionId)` so a stuck signer doesn't flood every - * sweep tick — the first hit per position is the loud one, - * subsequent ones drop to debug. Restart of the keeper resets - * the dedupe set, so a fix-and-restart re-enables the error - * for any new occurrences. We use `error` rather than `warn` - * because both are unrecoverable without operator action: the - * position will *never* be cash-settled by this keeper unless - * the cause is fixed: - * - signer != validator → keeper has no way to authorize - * `closeDelivery`; rotate LIQUIDATOR_PRIVATE_KEY to match - * `Futures.validatorAddress()` or have the position - * participant call `closeDelivery` themselves. - * - past `deliveryAt + duration` → the contract has hard- - * coded the window closed; the position is permanently - * stuck open from a settlement standpoint. + * because someone else already settled it (`PositionNotExists`). + * We drop and move on. + * debug — transient, will retry on the next sweep with no operator action + * needed (not yet matured, oracle stale, oracle invalid). + * + * Since `settlePosition` is permissionless and has no upper time bound, the + * old operator-paging cases (wrong validator key, expired settlement window) + * no longer exist — every revert here is either benign or self-healing. */ - private logRecoverableRevert( - revert: RecoverableRevert, - positionId: Hex, - blameSeller: boolean, - ): void { + private logRecoverableRevert(revert: RecoverableRevert, positionId: Hex): void { if (revert === "PositionNotExists") { this.logger.info( { positionId, revert }, - "delivery: position already closed by someone else — dropping from index", - ); - return; - } - if ( - revert === "OnlyValidatorOrPositionParticipant" || - revert === "PositionDeliveryExpired" - ) { - const key = `${revert}:${positionId}`; - if (this.warned.has(key)) { - this.logger.debug( - { positionId, blameSeller, revert }, - "delivery: closeDelivery skipped (already-reported recoverable revert)", - ); - return; - } - this.warned.add(key); - const message = - revert === "OnlyValidatorOrPositionParticipant" - ? "delivery: closeDelivery rejected — keeper signer is not Futures.validatorAddress(); position will not be settled until LIQUIDATOR_PRIVATE_KEY is rotated or the position participant calls closeDelivery" - : "delivery: closeDelivery rejected — settlement window already expired; position is permanently stuck open and can no longer be cash-settled by the contract"; - this.logger.error( - { positionId, blameSeller, revert, signer: this.chain.account.address }, - message, + "delivery: position already settled by someone else — dropping from index", ); return; } // PositionDeliveryNotStartedYet, OracleStale, InvalidOracle — sweep retries. this.logger.debug( - { positionId, blameSeller, revert }, - "delivery: closeDelivery skipped (transient revert, will retry)", + { positionId, revert }, + "delivery: settlePosition skipped (transient revert, will retry)", ); } @@ -1111,8 +968,6 @@ const MAX_TIMEOUT_MS = 2_147_483_647; type RecoverableRevert = | "PositionNotExists" | "PositionDeliveryNotStartedYet" - | "PositionDeliveryExpired" - | "OnlyValidatorOrPositionParticipant" // Hashprice oracle hasn't ticked within `MAX_ORACLE_STALENESS` (1h). The // periodic sweep keeps the position queued; the next attempt succeeds as // soon as the oracle posts a fresh round. @@ -1123,8 +978,6 @@ type RecoverableRevert = const RECOVERABLE_REVERTS = new Set([ "PositionNotExists", "PositionDeliveryNotStartedYet", - "PositionDeliveryExpired", - "OnlyValidatorOrPositionParticipant", "OracleStale", "InvalidOracle", ]); diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 21705a9..ddbfbb7 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -141,10 +141,9 @@ async function main(): Promise { webhookIngester = new WebhookIngester(config, tracker, logger); } - // Optional: cash-settle futures positions at their delivery date. Off by - // default — only useful when the keeper signer is the Futures contract's - // configured `validatorAddress`. See `delivery/coordinator.ts` for the - // full authorization story. + // Optional: cash-settle futures positions at their maturity (`deliveryAt`) + // via the permissionless `Futures.settlePosition`. Off by default. Any keeper + // signer can settle — no validator role required. See `delivery/coordinator.ts`. let deliveryCoordinator: DeliveryCoordinator | undefined; if (config.delivery.enabled) { deliveryCoordinator = new DeliveryCoordinator( diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 95c409b..49262ed 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -61,7 +61,6 @@ function makeConfig(overrides: Partial = {}): Config { coordinator: { confirmationBlocks: 1 }, delivery: { enabled: true, - blameSeller: true, sweepIntervalMs: 1_000_000, settleDelayMs: 0, bootstrapUsers: [], @@ -258,7 +257,7 @@ const FUTURES_ABI = parseAbi([ "error PositionNotExists()", "error OnlyValidatorOrPositionParticipant()", "error UnknownProblem()", - "function closeDelivery(bytes32 positionId, bool blameSeller)", + "function settlePosition(bytes32 positionId)", ]); /** Build the same shape of revert viem hands `simulateContract` callers. */ @@ -266,7 +265,7 @@ function makeRevert(errorName: string): BaseError { const inner = new ContractFunctionRevertedError({ abi: FUTURES_ABI, data: undefined, - functionName: "closeDelivery", + functionName: "settlePosition", }); (inner as unknown as { data: { errorName: string } }).data = { errorName }; const outer = new BaseError("simulated revert"); @@ -275,23 +274,15 @@ function makeRevert(errorName: string): BaseError { } describe("DeliveryCoordinator: revert classification", () => { - it("recognises the contract's settlement-window guards as recoverable", () => { + it("recognises the maturity guard and benign already-settled as recoverable", () => { assert.equal( __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryNotStartedYet")), "PositionDeliveryNotStartedYet", ); - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryExpired")), - "PositionDeliveryExpired", - ); assert.equal( __testing.decodeRecoverableRevert(makeRevert("PositionNotExists")), "PositionNotExists", ); - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("OnlyValidatorOrPositionParticipant")), - "OnlyValidatorOrPositionParticipant", - ); }); it("treats oracle freshness reverts as recoverable so the sweep retries", () => { @@ -299,6 +290,19 @@ describe("DeliveryCoordinator: revert classification", () => { assert.equal(__testing.decodeRecoverableRevert(makeRevert("InvalidOracle")), "InvalidOracle"); }); + it("no longer classifies retired validator/window reverts as recoverable", () => { + // settlePosition is permissionless with no upper time bound, so these + // closeDelivery-era reverts can never occur and are treated as unknown. + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryExpired")), + undefined, + ); + assert.equal( + __testing.decodeRecoverableRevert(makeRevert("OnlyValidatorOrPositionParticipant")), + undefined, + ); + }); + it("does not classify unknown reverts as recoverable", () => { assert.equal(__testing.decodeRecoverableRevert(makeRevert("UnknownProblem")), undefined); assert.equal(__testing.decodeRecoverableRevert(new Error("boom")), undefined); @@ -337,7 +341,7 @@ describe("DeliveryCoordinator: live event handling", () => { }); describe("DeliveryCoordinator: settle()", () => { - it("simulates and broadcasts closeDelivery with the configured blame side", async () => { + it("simulates and broadcasts settlePosition for the matured lot", async () => { let simulatedArgs: readonly unknown[] | undefined; const chain = makeChain({ simulate: (args) => { @@ -346,11 +350,7 @@ describe("DeliveryCoordinator: settle()", () => { }, writeHash: "0xfeed", }); - const coordinator = new DeliveryCoordinator( - chain, - makeConfig({ blameSeller: false }), - silentLogger, - ); + const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coordinator.start(); // Inject directly via the live watcher stub so we don't have to wait // on a real timer. @@ -362,7 +362,7 @@ describe("DeliveryCoordinator: settle()", () => { }); await coordinator.settle(POSITION_A); - assert.deepEqual(simulatedArgs, [POSITION_A, false]); + assert.deepEqual(simulatedArgs, [POSITION_A]); assert.equal(chain.calls.writes.length, 1); assert.equal(coordinator.has(POSITION_A), false, "settled position is dropped"); coordinator.stop(); @@ -389,25 +389,6 @@ describe("DeliveryCoordinator: settle()", () => { coordinator.stop(); }); - it("drops positions on PositionDeliveryExpired (no second attempt possible)", async () => { - const chain = makeChain({ - simulate: () => ({ error: makeRevert("PositionDeliveryExpired") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - - await coordinator.settle(POSITION_A); - assert.equal(chain.calls.writes.length, 0); - assert.equal(coordinator.has(POSITION_A), false, "expired position is dropped"); - coordinator.stop(); - }); - it("drops positions on PositionNotExists (already settled by someone else)", async () => { const chain = makeChain({ simulate: () => ({ error: makeRevert("PositionNotExists") }), @@ -442,23 +423,6 @@ describe("DeliveryCoordinator: settle()", () => { coordinator.stop(); }); - it("keeps positions on OnlyValidatorOrPositionParticipant (signer not validator)", async () => { - const chain = makeChain({ - simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - assert.equal(coordinator.has(POSITION_A), true, "auth misconfig is recoverable"); - coordinator.stop(); - }); - it("logs unknown simulate reverts at error and skips them from the batch", async () => { // New batching contract: an unknown simulate revert does NOT take down // the keeper. Instead it's logged at error level (visible to ops) and @@ -533,7 +497,7 @@ describe("DeliveryCoordinator: settle()", () => { }); describe("DeliveryCoordinator: settleBatch()", () => { - it("bundles N closeDelivery calls into one Futures.multicall(bytes[]) tx", async () => { + it("bundles N settlePosition calls into one Futures.multicall(bytes[]) tx", async () => { // The whole point of batching: even with 3 candidates, we want exactly // one writeContract call (one nonce) so a concurrent manual send or a // stale pending tx can't cause `replacement transaction underpriced`. @@ -567,7 +531,7 @@ describe("DeliveryCoordinator: settleBatch()", () => { assert.equal(writeArgs.length, 1, "exactly one broadcast — one nonce, no race"); const req = writeArgs[0] as { functionName: string; args: readonly [readonly `0x${string}`[]] }; assert.equal(req.functionName, "multicall"); - assert.equal(req.args[0].length, 3, "three encoded closeDelivery calls in the bundle"); + assert.equal(req.args[0].length, 3, "three encoded settlePosition calls in the bundle"); for (const id of [POSITION_A, POSITION_B, POSITION_C]) { assert.equal(coordinator.has(id), false, `${id} dropped after multicall confirms`); } @@ -741,8 +705,9 @@ describe("DeliveryCoordinator: backfill", () => { coordinator.stop(); }); - it("drops positions whose entire settlement window has already expired", async () => { - // 7 days * 86400 s + extra → window expired + it("still settles long-ago matured positions (settlePosition has no expiry window)", async () => { + // Pre-cash-settlement this would be pruned as "window expired". Now any + // matured position stays settleable forever, so backfill settles it. const longAgo = BigInt(Math.floor(Date.now() / 1000)) - 8n * 86_400n; let simulateCount = 0; const chain = makeChain({ @@ -752,7 +717,6 @@ describe("DeliveryCoordinator: backfill", () => { return { request: { ok: true } }; }, writeHash: "0xfeed", - deliveryDurationDays: 7, history: { LotCreated: [lotCreatedLog(POSITION_A, longAgo)], }, @@ -761,8 +725,9 @@ describe("DeliveryCoordinator: backfill", () => { await coordinator.start(); await coordinator.backfill(0n, 10_000n); - assert.equal(simulateCount, 0, "no settlement attempted for expired window"); - assert.equal(coordinator.has(POSITION_A), false, "expired position pruned"); + assert.equal(simulateCount, 1, "matured position is settled, not abandoned"); + assert.equal(chain.calls.writes.length, 1); + assert.equal(coordinator.has(POSITION_A), false, "settled position dropped"); coordinator.stop(); }); @@ -775,94 +740,11 @@ describe("DeliveryCoordinator: backfill", () => { }); }); -describe("DeliveryCoordinator: validator pre-flight", () => { - it("start() throws when keeper signer is not Futures.validatorAddress()", async () => { - const OTHER = "0x0000000000000000000000000000000000000001" as Address; - const chain = makeChain({ validator: OTHER }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await assert.rejects( - () => coordinator.start(), - /signer is not the futures validator/i, - "boot must fail loudly so the orchestrator restart loop pages on-call", - ); - }); - - it("start() succeeds when keeper signer matches the validator", async () => { - const chain = makeChain({ validator: VALIDATOR }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator.stop(); - }); -}); - describe("DeliveryCoordinator: recoverable-revert log severity", () => { - // The whole point of differentiated logging: at the default `info` log - // level, an operator should *immediately* see operational misconfigs - // (wrong validator key, missed delivery window) without having to flip - // LOG_LEVEL=debug. Transient reverts the sweep will retry stay at debug - // so they don't drown out everything else. - - it("errors once when the keeper signer is not the validator (page-worthy)", async () => { - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ - simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - const errors = calls.filter((c) => c.level === "error"); - assert.equal(errors.length, 1, "first occurrence is an error the operator must see"); - assert.equal(errors[0]?.obj.revert, "OnlyValidatorOrPositionParticipant"); - assert.match(errors[0]?.msg ?? "", /signer is not Futures\.validatorAddress/); - assert.equal(coordinator.has(POSITION_A), true, "auth misconfig is recoverable; position kept"); - coordinator.stop(); - }); - - it("dedupes repeated auth-failure errors to debug to avoid flooding", async () => { - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ - simulate: () => ({ error: makeRevert("OnlyValidatorOrPositionParticipant") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - await coordinator.settle(POSITION_A); - await coordinator.settle(POSITION_A); - const errors = calls.filter((c) => c.level === "error"); - assert.equal(errors.length, 1, "subsequent attempts on same position do not re-error"); - }); - - it("errors once when settlement window has expired and drops the position", async () => { - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ simulate: () => ({ error: makeRevert("PositionDeliveryExpired") }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - const errors = calls.filter((c) => c.level === "error"); - assert.equal(errors.length, 1); - assert.equal(errors[0]?.obj.revert, "PositionDeliveryExpired"); - assert.match(errors[0]?.msg ?? "", /settlement window already expired/); - assert.equal(coordinator.has(POSITION_A), false, "expired window → drop"); - coordinator.stop(); - }); + // settlePosition is permissionless with no expiry window, so the only + // terminal revert is the benign "already settled" case (logged at info). + // Everything else is transient and stays at debug so the sweep retries + // quietly. it("logs at info (not error) when someone else already settled the position", async () => { const { logger, calls } = makeRecordingLogger(); diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 52503fe..66733c5 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -55,10 +55,9 @@ export interface KeeperHarness { predictor: PredictiveCoordinator; /** * Only present when `BuildKeeperOverrides.delivery` is set. Tests that - * exercise the delivery module must pass `delivery: true` and ensure the - * keeper signer equals the Futures contract's `validatorAddress` — - * otherwise every `closeDelivery` simulate fails authorization and the - * sweep silently no-ops. + * exercise the delivery module pass `delivery: true`. Settlement via + * `settlePosition` is permissionless, so the keeper signer needs no + * special role. */ delivery?: DeliveryCoordinator; start(): Promise; @@ -74,10 +73,9 @@ export interface BuildKeeperOverrides { /** Inject your own keeper signer key. Defaults to Hardhat account #3. */ liquidatorPrivateKey?: `0x${string}`; /** - * Wire up the optional `DeliveryCoordinator`. Defaults to false. When - * true, tests should also pass `liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4]` - * (the validator) so `closeDelivery` simulations pass the contract's - * `_msgSender() == validatorAddress` guard. + * Wire up the optional `DeliveryCoordinator`. Defaults to false. Settlement + * via `settlePosition` is permissionless, so any keeper signer works — no + * need to align with the Futures `validatorAddress`. */ delivery?: boolean; /** @@ -88,7 +86,7 @@ export interface BuildKeeperOverrides { */ deliveryBootstrapUsers?: readonly Address[]; /** - * Maximum closeDelivery calls bundled into one Futures.multicall tx by + * Maximum settlePosition calls bundled into one Futures.multicall 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). @@ -263,7 +261,6 @@ function buildConfig( }, delivery: { enabled: overrides.delivery === true, - blameSeller: true, // Tighter than production so tests don't have to wait a minute for // the safety-net sweep when they want to verify backfill behaviour. sweepIntervalMs: 1_000, diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 927239b..86e8070 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -233,7 +233,6 @@ export async function deployStack(rpcUrl: string): Promise { "initialize", [ hashpriceOracle, - validator.account.address, FUTURES_LIQUIDATION_MARGIN_PCT, FUTURES_SPEED_HPS, MIN_PRICE_INCREMENT, @@ -336,14 +335,6 @@ export async function deployStack(rpcUrl: string): Promise { "setLiquidationFee", [FUTURES_LIQUIDATION_FEE], ); - await write( - publicClient, - owner.client, - futures, - futuresArt.abi, - "setValidatorURL", - ["//keeper-test-validator"], - ); // ── Fund & approve test wallets ─────────────────────────────────────── for (const w of [alice, bob, liquidator, validator, dave]) { diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index e829850..0768a92 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -184,7 +184,7 @@ export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) = /** * Earliest block at which `Futures.LotClosed(lotId)` was * emitted. Used by the delivery-coordinator e2e tests to confirm the keeper - * actually sent `closeDelivery` for a specific position id. + * actually settled a specific position id via `settlePosition`. */ export async function readLotClosedBlock( stack: DeployedStack, diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 5b09a9a..69f8a19 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -619,18 +619,16 @@ describe("DeliveryCoordinator (live RPC)", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds a single long futures contract created - // at fixture time. The keeper boots with delivery enabled and the - // *validator* signing key — so `closeDelivery` simulations clear - // the contract's `_msgSender() == validatorAddress` guard. + // at fixture time. The keeper boots with delivery enabled. (The + // validator key is used here for historical parity, but `settlePosition` + // is permissionless — see the dedicated non-validator test below.) // // We then fast-forward the chain past `deliveryAt` and trigger one - // sweep. The contract's `_closeAndCashSettleDelivery` cash-settles - // the entire window at the current market price (positionElapsedTime - // = 0 → no contract-price portion), and emits `LotClosed` - // followed by `LotClosed`. + // sweep. `settlePosition` cash-settles the full position notional at the + // current market price and emits `LotClosed(SETTLED)`. const ctx = await loadFixture(futuresLongCrashFixture, testClient); keeper = buildKeeper(ctx, { - liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator (parity; not required) delivery: true, }); await keeper.start(); @@ -651,7 +649,7 @@ describe("DeliveryCoordinator (live RPC)", () => { assert.ok(keeper.delivery.has(id), `backfill should index position ${id}`); } - // Fast-forward past `deliveryAt`. `closeDelivery` requires + // Fast-forward past `deliveryAt`. `settlePosition` requires // `block.timestamp >= position.deliveryAt`, and `block.timestamp` is // only advanced once a block is mined at the new clock. const deliveryAt = ctx.config.futuresFirstDeliveryDate; @@ -660,9 +658,8 @@ describe("DeliveryCoordinator (live RPC)", () => { // The hashprice oracle has been silent for 7 days — refresh it so // `_getHashpriceUsd` doesn't revert `OracleStale` inside - // `closeDelivery`. We re-post the entry price; the cash-settlement - // formula uses this as the "current market price" applied to the - // full delivery window (positionElapsedTime = 0). + // `settlePosition`. We re-post the entry price; the settlement formula + // uses this as the mark applied to the full position notional. await ctx.bumpHashprice(ctx.config.initialHashprice); await keeper.delivery.sweep(); @@ -718,8 +715,8 @@ describe("DeliveryCoordinator (live RPC)", () => { // Precondition: alice's position was created at fixture time and // its `deliveryAt` is *already in the past* by the time the keeper // boots. The contract is the spec for "missing delivery": until - // someone calls `closeDelivery` the position lingers, and the - // settlement window stays open for `deliveryDurationDays`. + // someone calls `settlePosition` the position lingers, and (unlike the + // old closeDelivery window) it stays settleable indefinitely. // // Contract under test: `backfill()` discovers the position from // history AND its trailing `sweep()` settles it on the same boot — @@ -859,39 +856,39 @@ describe("DeliveryCoordinator (live RPC)", () => { ); it( - "refuses to start when the keeper signer is not the futures validator", + "settles with a non-validator signer (settlePosition is permissionless)", { timeout: 60_000 }, async () => { - // Operator-safety contract: if `DELIVERY_KEEPER_ENABLED=true` is set - // but `LIQUIDATOR_PRIVATE_KEY` does not derive to - // `Futures.validatorAddress()`, the coordinator must throw at start. - // Bubbles up to `main().catch` → `process.exit(1)`; the orchestrator - // (k8s, systemd) sees the crash, healthcheck flips to 503, and - // standard infra alerting (CrashLoopBackOff etc.) pages on-call. - // The alternative — silent `simulateContract` reverts at debug level — - // is invisible at the default `info` log level and was the actual - // production failure mode that motivated this check. + // settlePosition has no validator/participant gate, so a stock keeper + // running the DEFAULT liquidator key (account #3, NOT the validator #4) + // must still be able to cash-settle matured positions. This is the + // whole point of the cash-settlement migration: no special role needed. const ctx = await loadFixture(futuresLongCrashFixture, testClient); - const k = buildKeeper(ctx, { - // Default liquidator key (account #3), NOT the validator (#4). + keeper = buildKeeper(ctx, { + // Note: no `liquidatorPrivateKey` override → default account #3. delivery: true, }); - keeper = k; - await assert.rejects( - () => k.start(), - /signer is not the futures validator/i, - "boot must fail loudly so the operator cannot miss the misconfig", - ); + await keeper.start(); + assert.ok(keeper.delivery); - // Sanity: nothing was indexed and no positions were settled. const alice = ctx.accounts.alice.account.address; const positionsBefore = await readFuturesPositionIds(ctx, alice); assert.ok(positionsBefore.length > 0, "fixture should have created positions"); + + await keeper.delivery.backfill(0n, 10_000n); + + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + await testClient.mine({ blocks: 1 }); + await ctx.bumpHashprice(ctx.config.initialHashprice); + + await keeper.delivery.sweep(); + + await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { - assert.equal( - await readLotClosedBlock(ctx, id), - null, - `position ${id} must not be settled by a misconfigured keeper`, + assert.ok( + (await readLotClosedBlock(ctx, id)) !== null, + `position ${id} should be settled by a permissionless (non-validator) signer`, ); } }, diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index 3bdb74a..a8d9e0d 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -56,7 +56,6 @@ const STUB_CONFIG: Config = { }, delivery: { enabled: false, - blameSeller: true, sweepIntervalMs: 60_000, settleDelayMs: 0, bootstrapUsers: [], From 6edb8d679664793b659de3b6a4ef58ebff3ceeec Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 19 Jun 2026 19:23:53 +0200 Subject: [PATCH 072/180] chore: update futures-marketplace-abi dependency to a specific GitHub version --- keeper/pnpm-lock.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 80754fc..6442b01 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: link:../../futures-marketplace/contracts/abi - version: link:../../futures-marketplace/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#feat/no-delivery&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -165,6 +165,10 @@ 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/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-LD5SVAxNVy6pGo1lSoK5paf8cvzpVvpuVFL3zWzsr2vaPZDn8sRVXnNEjX2K6kF5H6N6QvjSDw86pQHGevfBxQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118} + version: 0.0.0 + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -360,6 +364,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi: {} + help-me@5.0.0: {} isows@1.0.7(ws@8.20.1): From 5b9426570ace7fd699570b001849f647a153466b Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 20 Jun 2026 06:13:44 +0200 Subject: [PATCH 073/180] fix: patch abi --- keeper/pnpm-lock.yaml | 5 +++-- keeper/pnpm-workspace.yaml | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 6442b01..f2261c2 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: patchedDependencies: collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d + futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a importers: @@ -23,7 +24,7 @@ importers: version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: specifier: github:Lumerin-protocol/futures-marketplace#feat/no-delivery&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -364,7 +365,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index 0adc312..7064f91 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -1,9 +1,6 @@ -allowBuilds: - "@arbitrum/nitro-contracts": set this to true or false - keccak: set this to true or false - secp256k1: set this to true or false autoInstallPeers: false blockExoticSubdeps: false patchedDependencies: collateral-margin-abi: patches/collateral-margin-abi@.patch derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch + futures-marketplace-abi: patches/futures-marketplace-abi@.patch From 15963f46ac9b1ef232046d589c80f3754028e729 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 20 Jun 2026 06:49:00 +0200 Subject: [PATCH 074/180] fix: deps resolution --- keeper/package.json | 4 ++-- keeper/pnpm-lock.yaml | 55 +++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index a68b4a6..7dfac2d 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -20,9 +20,9 @@ }, "dependencies": { "amaro": "^1.1.9", - "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi", + "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/no-delivery&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#0aae92cd86a01b187ea20544b6e65784b067d118&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index f2261c2..c4bfeba 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -15,26 +15,26 @@ importers: dependencies: amaro: specifier: ^1.1.9 - version: 1.1.9 + version: 1.1.10 collateral-margin-abi: - specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) + specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#feat/no-delivery&path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#0aae92cd86a01b187ea20544b6e65784b067d118&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 viem: specifier: ^2.48.8 - version: 2.51.0 + version: 2.52.2 devDependencies: '@types/node': specifier: ^22.0.0 - version: 22.19.19 + version: 22.19.21 '@typescript/native-preview': specifier: 7.0.0-dev.20260511.1 version: 7.0.0-dev.20260511.1 @@ -71,8 +71,8 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/node@22.19.21': + resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': resolution: {integrity: sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw==} @@ -132,16 +132,16 @@ packages: zod: optional: true - amaro@1.1.9: - resolution: {integrity: sha512-Qx5+iHi3mKWz95XNx/WPFl8yRMZEGNoRZDaOkoej72kxAo20FbDVx7jALcvyOn/N3+h+GboKip49yba7xqLlKA==} + amaro@1.1.10: + resolution: {integrity: sha512-ceFv+QA3SlhFsn0hu8Q8oyj36YZdIgoJFpyS2sGJGK2dyncwcMWuBlNzhXfc1oLWtbDWM2Ol2rrzOYa+HNyEjg==} engines: {node: '>=22'} atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b} version: 0.0.0 colorette@2.0.20: @@ -150,8 +150,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/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec} version: 0.0.0 end-of-stream@1.4.5: @@ -167,7 +167,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-LD5SVAxNVy6pGo1lSoK5paf8cvzpVvpuVFL3zWzsr2vaPZDn8sRVXnNEjX2K6kF5H6N6QvjSDw86pQHGevfBxQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118} version: 0.0.0 help-me@5.0.0: @@ -192,9 +192,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: - resolution: {tarball: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a} - version: 0.14.26-386a343.0 + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -256,8 +255,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - viem@2.51.0: - resolution: {integrity: sha512-8C0Ca+eEapXE29vHMUW59NqKENl1X4s9P6xSNC9Nvw6EvAeAhn/LNUlgztk6TOw7KN1Gzz5a/n9Wv4okUfmY9g==} + viem@2.52.2: + resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -306,7 +305,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@types/node@22.19.19': + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -343,17 +342,17 @@ snapshots: abitype@1.2.3: {} - amaro@1.1.9: {} + amaro@1.1.10: {} atomic-sleep@1.0.0: {} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/dfe9c45a0d86ada37b3e0414aa6df54fcf97a0e6#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} colorette@2.0.20: {} dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b593843ce4312ea27e0cf3cd4ab321014187487#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -383,7 +382,7 @@ snapshots: dependencies: wrappy: 1.0.2 - ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: + ox@0.14.29: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -463,7 +462,7 @@ snapshots: undici-types@6.21.0: {} - viem@2.51.0: + viem@2.52.2: dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -471,7 +470,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3 isows: 1.0.7(ws@8.20.1) - ox: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a + ox: 0.14.29 ws: 8.20.1 transitivePeerDependencies: - bufferutil From a9c69e4431b2543e4c335ac42eaf62e3fcbb3562 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 6 Jul 2026 17:06:04 +0200 Subject: [PATCH 075/180] feat(keeper): liquidate-to-IM-buffer orchestration across futures + perps Rework the keeper to close underwater accounts down to the IM buffer instead of one lot/order at a time: - Off-chain solvers (`predict/solve.ts`): size a worst-first set of futures lots (`solveFuturesLotsToTarget`, balanced across expirations) and a partial perps close quantity (`solvePerpCloseToTarget`) against whole-portfolio MM/IM, with simulation helpers to predict post-close health. - Venue adapters gain `reduceToTarget`: futures batches lot ids into `liquidatePositions` (chunked by `maxLotsPerLiquidationTx`); perps sizes a single partial `liquidatePosition(user, closeQty)`. - Planner drives sequential worst-venue-first sweeps, re-snapshotting health between venues until MM is restored. - Snapshot carries per-lot `deliveryAt` so lot selection can balance across futures expirations. - Model zero keeper-incentive fees everywhere (incentives disabled on-chain). Add unit + integration coverage: solver targets, `reduceToTarget`, multi-expiry futures balancing, and cross-venue partial/substantial sweeps into the [MM, IM] band. Add `docs/liquidation-orchestration.md`. Also pin `matchstick-ts` to a fixed commit and drop the postinstall patch script in points-indexer. --- docs/liquidation-orchestration.md | 388 ++++++++++++++++++ keeper/src/config.ts | 14 + keeper/src/coordinator/planner.ts | 139 ++++--- keeper/src/predict/snapshot.ts | 1 + keeper/src/predict/solve.ts | 285 ++++++++++++- keeper/src/predict/types.ts | 9 + keeper/src/tx/liquidate.ts | 5 + keeper/src/venues/futures.ts | 65 ++- keeper/src/venues/perps.ts | 53 ++- keeper/src/venues/types.ts | 41 +- keeper/tests/coordinator/planner.test.ts | 121 ++++-- keeper/tests/integration/buildKeeper.ts | 2 +- keeper/tests/integration/helpers.ts | 145 +++++++ .../integration/keeper.integration.test.ts | 335 +++++++++++++++ keeper/tests/integration/scenarios.ts | 329 +++++++++++++++ keeper/tests/predict/mm.test.ts | 16 +- keeper/tests/predict/snapshot.test.ts | 9 +- keeper/tests/predict/solve.test.ts | 2 +- keeper/tests/predict/solveTarget.test.ts | 228 ++++++++++ keeper/tests/runtime/healthcheck.test.ts | 2 +- keeper/tests/venues/reduceToTarget.test.ts | 244 +++++++++++ points-indexer/package.json | 5 +- points-indexer/pnpm-lock.yaml | 24 +- .../scripts/patch-matchstick-ts.mjs | 97 ----- 24 files changed, 2309 insertions(+), 250 deletions(-) create mode 100644 docs/liquidation-orchestration.md create mode 100644 keeper/tests/predict/solveTarget.test.ts create mode 100644 keeper/tests/venues/reduceToTarget.test.ts delete mode 100644 points-indexer/scripts/patch-matchstick-ts.mjs diff --git a/docs/liquidation-orchestration.md b/docs/liquidation-orchestration.md new file mode 100644 index 0000000..e59bf6b --- /dev/null +++ b/docs/liquidation-orchestration.md @@ -0,0 +1,388 @@ +# Liquidation orchestration under shared collateral + +This note captures how the keeper drives **liquidate-to-IM-buffer** liquidations +across Futures and Perps when a single `CollateralVault` balance backs positions +on *both* venues, and why the mechanism is designed the way it is. It is the +reference for the `reduceToTarget` venue surface, the coordinator/planner loop, +gas-bounded chunking, and the keeper-incentive (fee) model. + +## The problem + +- **Trigger vs. target are different margins.** An account becomes liquidatable + when its portfolio balance drops **below Maintenance Margin (MM)**. But we do + not want to fully liquidate — we want to close *just enough* to bring it back + into the **Initial Margin (IM) buffer**, i.e. land the balance in the + `[MM, IM]` band. Fully closing every underwater account is bad UX and bleeds + users through unnecessary realized losses + fees. + +- **Collateral is shared.** `computePortfolioMM(user)` / `computePortfolioIM(user)` + are portfolio-level (perps net position + each futures lot + options), read + against **one** vault balance. Closing part of a position on Perps changes the + *portfolio* IM/MM and therefore changes whether the Futures leg still needs to + be touched — and vice-versa. The venues are **not** independent. + +- **Gas is bounded.** A whale can hold many futures lots (up to + `MAX_ORDERS_PER_PARTICIPANT`-adjacent counts of positions) or a large perps + net position plus a flood of small resting orders. A single "close everything + needed" transaction can exceed the block gas limit. + +## Target behaviour + +For an underwater account the keeper closes **worst-first** exposure until the +portfolio balance re-enters `[MM, IM]`: + +- **Futures**: `liquidatePositions(participant, positionIds[])` closes a + keeper-chosen **subset of lots** in one tx. No per-lot margin recompute; a + single end-of-tx `OverLiquidation` guard enforces "with lots remaining and a + real buffer (`IM > MM`), leftover balance ≤ IM". +- **Perps**: `liquidatePosition(user, closeQty)` closes a keeper-chosen + **partial quantity** of the net position in one tx, with the same end-of-tx + `OverLiquidation` guard. + +The keeper sizes the subset / quantity **off-chain** (see +`keeper/src/predict/solve.ts`: `solveFuturesLotsToTarget`, +`solvePerpCloseToTarget`) against a fresh snapshot, using off-chain replicas of +the contract close math (`simulateFuturesClose`, `simulatePerpClose`) so the +band predicate the solver optimises against is exactly the one the contract +enforces. + +## Orchestration algorithm (the planner) + +The coordinator is **sequential and re-snapshots after every step**. This is the +key discipline that makes shared collateral tractable: never plan two venues off +one stale snapshot — a close on venue A changes venue B's surplus. + +``` +run(user): + health = readHealth(user) # portfolio MM surplus + if health.mmSurplus >= 0: return healthy + + # 1. Orders leg — clear resting orders on every venue first. + # Orders alone can break MM, and positions can't be closed while + # orders are open (OrdersStillOpen). + for venue in venues: venue.liquidateOrders(user) + re-read health; if healthy: return + + # 2. Position leg — loop, worst-venue-first. + for iter in 0..MAX_POSITION_ITERATIONS: + ranked = rankVenuesByLoss(user) # sum(unrealizedLoss) desc, notional tiebreak + if ranked empty: return badDebt # nothing left to close, still < MM + worst = first actionable venue + result = worst.reduceToTarget(user) # ONE gas-bounded batched tx + if result == ordersStillOpen: replay orders leg + if result == nothingToClose: park venue + re-read health; if healthy: return liquidated + return stalled (re-queue) # made progress, ran out of budget +``` + +`reduceToTarget` encapsulates: snapshot read → off-chain sizing → one batched +call. The planner is pure orchestration; venues own calldata, batching, gas +estimation, and the not-liquidatable / unprofitable skip predicates. + +### Why sequential (not one giant multi-venue plan) + +- A partial close on one venue frees shared collateral and can move the *other* + venue from "must reduce" to "already fine" — computing both legs from one + snapshot would over-liquidate the second venue. +- Re-snapshotting per step also **adapts to price drift** between txs: each + `reduceToTarget` sizes against the latest mark, so a mid-liquidation price move + simply changes the next chunk rather than invalidating a precomputed plan. + +## Gas-bounded chunking (Option A) + +We do **not** try to fit an unbounded liquidation into one tx. Instead: + +- **Futures** caps the number of lots per `liquidatePositions` call at + `maxLotsPerLiquidationTx` (keeper config, env `FUTURES_MAX_LOTS_PER_LIQUIDATION_TX`). + `reduceToTarget` sends **one worst-first chunk** (the deepest-loss lots, capped) + and returns. The account may remain between IM and MM after a chunk — that is + acceptable (still de-risked relative to the MM trigger). +- The **planner loop re-invokes** `reduceToTarget` on the still-worst venue, + re-snapshotting each time, until healthy or the iteration budget is exhausted. + `MAX_POSITION_ITERATIONS` is sized generously so a large book drains across + several chunks within one `run`. +- **Perps** does not need position chunking: a single `liquidatePosition(user, + closeQty)` closes any quantity of the *one* net position in O(1) settlement. + The perps gas concern is the **order flood**, handled by bundling + `liquidateOrder` calls via `multicallStopOnFailure` (see below). + +**Consequence to accept:** splitting into chunks means intermediate states can +sit in `(MM, IM)` — recovered past the trigger but not yet fully into the buffer. +The next chunk (or the next sweep) finishes the job. This is strictly better than +the old one-lot-per-tx churn and is safe because each chunk only ever reduces +risk. + +## Keeper incentives / fee model + +> **Status (current code): keeper incentives are DISABLED.** No `liquidationFee` +> is transferred on any liquidation path in either venue — `liquidatePosition`, +> `liquidatePositions`, `liquidateOrder(s)`, and their perps equivalents all emit +> a `0` fee and move no funds. The `liquidationFee` state variables and their +> owner setters are **retained** (so a future iteration can re-enable payouts +> without a storage migration), and the keeper's off-chain solvers model a **0 +> fee** so their balance projections match on-chain reality. The protocol runs +> the only keeper for now, so there is nothing to incentivise; the anti-farming +> design below is preserved as the reference for when incentives are turned back +> on. + +Liquidation fees drive keeper behaviour, so the fee model must not reward +value-destroying or farming behaviour. + +- **Futures**: a flat `liquidationFee` **per lot closed**. Because it scales with + the number of lots in the batch, a keeper is paid proportionally to the work + and gas it spent — there is no incentive to split a batch into many txs (that + only adds gas for the same total fee), nor to under-close (fewer lots = less + fee). + +- **Perps**: a **single flat `liquidationFee`, paid only if the close restored + the account to the buffer** (post-close balance ≥ MM), or on a full close. + - A naïve "flat fee on every `liquidatePosition` call regardless of + `closeQty`" is a **fee-farming vector**: an attacker/keeper could drip-close + an underwater position one sliver at a time, collecting a flat fee per call. + - Gating the fee on *reaching the buffer* removes that vector: intermediate + partial closes that leave the account still underwater earn **nothing**, so + there is no reward for slicing. The keeper is paid once, for the close that + actually cures the account (or for a full close in the bad-debt path). + - **Known caveat (accepted): cross-venue free-riding.** Under shared collateral, + a perps close can be the step that flips the *portfolio* healthy even though + the perps balance change was small — and vice-versa, a futures chunk can heal + the account so a would-be perps closer arrives to find nothing to do. With a + single coordinated keeper (`COORDINATOR_MAX_CONCURRENT = 1`) this is a + non-issue: the same operator performs all legs. It only matters in a + competitive multi-keeper market, where the "reached the buffer" gate can let a + late keeper capture the fee for a cure that an earlier keeper's work set up. + We accept this for now; a per-unit perps fee (fee ∝ `closeAbs`) is the + alternative if competitive keepers are introduced. + +## Small-order flood on Perps + +Concern: a user opens a flood of tiny-value resting orders; there is little +per-order incentive to cancel them during liquidation. + +Mitigations (contract already supports these): + +- **`MAX_ORDERS_PER_PARTICIPANT`** hard-caps how many resting orders one account + can hold, bounding the worst-case fan-out. +- **`minimumMarginPerOrder > 0`** makes each order carry real margin, so dust + orders are simply not creatable. Recommended to set non-zero in prod. +- **Bundling amortizes gas.** The keeper composes + `multicallStopOnFailure([liquidateOrder × N, liquidatePosition])`: the orders + are cleared in the *same* tx that closes the position, so the (large) position + fee amortizes the per-order gas. Clearing the orders is a prerequisite anyway + (`OrdersStillOpen`), so it is never "unpaid work" — it is part of the + profitable position liquidation. +- If dust remains uneconomical, an **insurance-fund bounty** for order clearing + is the escalation lever, but is not needed while the above hold. + +## Contract invariants relied upon + +- `liquidatePositions` / `liquidatePosition(user, closeQty)` do **not** recompute + margin per unit closed. They close the keeper-supplied amount and enforce a + **single** end-of-tx `OverLiquidation` guard: with exposure remaining and a + real buffer (`IM > MM`), leftover balance must be ≤ IM. A full close skips the + guard (the buffer is undefined once the position is gone — bad-debt path). +- Sizing the close so the account lands in `[MM, IM]` is the keeper's off-chain + responsibility; the guard is only a backstop against over-liquidation, not a + planner. +- Stale / foreign / already-closed ids in a Futures batch are **skipped**, not + reverted, so a snapshot race degrades to "closed fewer than planned" (the + planner's next iteration re-sizes) rather than a failed tx. + +--- + +# Design exploration: on-chain orchestration, verifiable rules, and lot aggregation + +Everything above describes the **current, keeper-driven** implementation +(sequential per-venue `reduceToTarget`, off-chain sizing, per-call +`OverLiquidation` guard). This section records the follow-on design discussion +about pushing more of the guarantee **on-chain** — a single cross-venue entry +point, a *verifiable* rule set so liquidation is not "random", and the data-model +change (lot aggregation) that makes deterministic liquidation actually +implementable. None of this is built yet; it is the agreed direction and its +trade-offs. + +## Why the per-venue guard alone can't split cleanly across venues + +The original intent was "restore margin to IM (or a bit more)". Two facts break +that framing: + +1. **"a bit more" (above IM) is impossible** with the per-call `OverLiquidation` + guard — you can land at IM but never above it. +2. **IM is a portfolio-level scalar, but closing happens per-venue.** You can't + independently tell futures and perps to each "reach IM": whichever closes + second overshoots and reverts. And you can't pre-allocate "x on futures, y on + perps" off one snapshot either, because closing one leg changes the portfolio + IM the other leg's sizing was based on (especially with cross-margin offsets). + +Plain multicall does **not** fix this: the guard lives *inside* each venue call, +so bundling still runs each intermediate check. The problematic orderings are +exactly the ones the per-venue guard forbids — e.g. a **hedged book** where you +must close the *winning* leg too; closing it first realizes profit and pushes +balance above IM before you've touched the losing leg, tripping the guard even +though the *final* state is perfectly in-band. + +## A single cross-venue entry point (LiquidationRouter) + +Prerequisite (already satisfied): `PortfolioMarginEngine.computePortfolioIM/MM` +spans **all three legs** — it holds an `IFutures` ref and folds in +`getFuturesOrderMargin`, `getFuturesUnrealizedPnl`, `getNetPositionDelta` +alongside perps + options. So a single on-chain portfolio-margin check that +covers both venues already exists. + +A `LiquidationRouter` (natural home: `collateral-margin`, next to Vault + PME) +with one entry point: + +``` +liquidate(user, futuresIds[], perpsCloseQty, feeTo): + require underwater(user) # balance < portfolioMM + clear orders on each venue (or require cleared) + futures.liquidateFor(user, futuresIds) # router-only, guard SUSPENDED + perps.liquidateFor(user, perpsCloseQty) # router-only, guard SUSPENDED + bal = vault.balanceOf(user) + im = PME.computePortfolioIM(user) + mm = PME.computePortfolioMM(user) + require(im <= mm || bal <= im) # single over-liquidation ceiling + pay unified fee to feeTo # one decision, portfolio-level +``` + +What it buys: **atomic multi-venue close verified once** (unlocks the +hedged-book orderings the per-venue guard forbids), a **single PME evaluation** +(gas), and a **unified fee decision** (which dissolves the cross-venue +free-riding caveat noted earlier). The keeper still computes the joint split +off-chain; the router just executes + verifies. + +What it costs: the safety invariant **moves into the router** — venues must +expose a guard-suspended, `onlyLiquidationRouter` close path (or honour an +EIP-1153 transient "liquidation in progress" flag), which widens the trusted +surface and couples three codebases (Futures + Perps + collateral-margin). If the +router forgets the final `≤ IM` check it can drain users, so it must be +governance-controlled and audited. It is also still gas-bounded, so whales still +chunk (the "verify once" then applies per chunk). + +## Fee model under chunking + +"Pay only when the account is **restored**" is a *completion* trigger, and +chunking means only the *last* tx completes — so intermediate chunks would do gas +work for zero fee, and a competitor could free-ride the cheap final chunk. The +resolution is to let the fee track each venue's **unit of work**: + +| Venue | Work per liquidation | Fee | Non-progress rule | +| --- | --- | --- | --- | +| Perps | constant (O(1) settlement, any size) | **flat per call** | pay only if the close restored the account to the band | +| Futures | proportional to lots closed | **flat per lot** | pay per lot actually closed | + +- **Perps stays flat**, not per-unit — the work is size-independent, so a + `rate × closeAbs` fee would mis-tax. Flat + partial + no-farming already + coexist via the restore gate: a correctly-sized partial collects one flat fee; + a sub-restoring sliver pays 0; once restored, further calls revert + `NotLiquidatable`. So there is at most one flat fee per underwater episode. + This is exactly the shipped perps behaviour, and it does **not** conflict with + chunking because perps never chunks. +- **Futures is already per-lot**, so each chunk is paid for the lots it closed — + chunking + fees already coexist, no change needed. +- **Optional completion bonus.** If you want to keep rewarding "reached the + buffer" while paying per-chunk, add a *one-time* flat bonus paid only on the + close where balance crosses back to ≥ MM. It is farming-proof (health crosses + MM at most once per episode) and it incentivises finishing the small final + chunk that the proportional/flat base fee under-pays. +- **Router:** pays the sum of the per-venue leg fees for that tx; the restore + gate, when used, is evaluated once at the portfolio level. + +Keep the perps rule as **"pay 0 if not restored"** (not "revert if not +restored"): the 0-fee-succeed form is more composable — a future router can reuse +the standalone perps call for its leg and settle the fee at the portfolio level +without needing a separate suspended entry point. + +## Making liquidation deterministic ("not random") and verifiable + +The router can cheaply verify the **result** (in-band) but **cannot** cheaply +verify a plan is *optimal* — optimality is a counterfactual over alternative +plans, i.e. re-running the solver on-chain. To remove keeper discretion without +re-solving, define a **canonical rule set** whose *conformance* is a boundary +check, not a search. + +### Rule set (each clause a router-enforced predicate) + +- **R0 — Validity.** Closed ids belong to the user; `closeQty ≤ |net|`. Cheap. +- **R1 — Trigger.** `balance < portfolioMM(user)` at entry. One read. +- **R2 — Orders before positions.** No position closed while any order rests; + order count = 0 after. Cheap. +- **R3 — Canonical close order.** One deterministic total priority over all + closeable positions across both venues, computable from on-chain state at the + current mark, with a deterministic tie-break `(venueRank, positionId)`. + Recommended key: **descending unrealized loss**. *Verify:* the closed set is a + **prefix** — `min(priority of closed) ≥ max(priority of still-open)`. +- **R4 — Sizing = IM ceiling.** With a position remaining, `balance ≤ IM`. +- **R5 — Maximality.** `balance ≥ IM − δ` (the continuous perps leg can fine-tune + balance to hit IM exactly, so the prefix length is *forced*, not chosen). +- **R6 — Restoration / bad-debt terminal.** Either `balance ≥ MM` (in-band) or + **all** positions closed (bad-debt full liquidation). + +R3 + R4 + R5 make the plan **unique** — the longest canonical prefix that sits at +the IM ceiling — so the liquidation is deterministic, and every clause is a +boundary predicate + a couple of margin reads, with **no on-chain re-solve**. + +Two free parameters are pure policy: the **priority key** (loss-first vs +margin-relief-first) and the **IM-ceiling vs MM-floor target** (buffer/less churn +vs minimal user harm). Fix them once and they become law. + +### The cost of strict verification, and the cheaper invariant tier + +Verifying R3's prefix property requires the priority of every *still-open* +position (to compute `max(open)`), so the router must **enumerate all of the +user's futures lots across all expiration dates** (plus the perp) — `O(total +lots)` storage reads even to close a few. And because the "most-underwater" key is +**mark-dependent**, no persisted sorted structure helps (price reshuffles the +order every block). + +Cheaper alternative — verify **invariants**, not the exact order (`O(1)`): + +- **Band:** `MM ≤ balance ≤ IM`. +- **Risk-monotonicity:** `|netDelta_after| ≤ |netDelta_before|` (reuses + `getNetPositionDelta`; one read before, one after), optionally + `stressLoss_after ≤ stressLoss_before`. + +This rules out the genuinely harmful selections — above all **hedge-stripping**, +where closing an offsetting leg and leaving a naked position *increases* +`|netDelta|` and is rejected. It bites precisely on mixed/hedged books (where +selection is dangerous) and is permissive on one-directional books (where the +band alone suffices, since all lots share a sign). The trade-off: invariants +constrain the plan to the *set* of sensible risk-reducing liquidations rather +than pinning one unique plan. + +**Recommendation:** enforce the `O(1)` invariant tier on-chain (band + +`netDelta`-monotonicity), and keep the canonical priority as the keeper's +off-chain policy (deterministic in practice, disciplined by competition). Pay for +strict on-chain prefix verification only against a concrete adversary the +invariants don't cover — and only after the aggregation change below makes it +affordable. + +## Enabler: aggregate lots into one net position per expiration date + +The `O(total lots)` scan is a direct consequence of the **data model**: today a +user holds *many individual futures lots* per delivery date, so the priority set +is unbounded and mark-dependent. Deterministic, cheaply-verifiable liquidation is +really only implementable if we **net a user's lots into a single position per +expiration date**: + +- The closeable set collapses from "all lots across all expiries" to **one net + position per delivery date** — a small, bounded set (`#expiries`, not + `#lots`). R3's prefix scan becomes `O(#expiries)` instead of `O(#lots)`. +- Each per-expiry position becomes a **single signed net quantity** — i.e. it + behaves like the perps net position: **continuous**, so a partial close can + fine-tune balance to the IM ceiling **exactly** (R5 becomes exact and cheap on + every venue, not just perps), and the awkward "which discrete lot is the last + one" problem for maximality disappears. +- Sizing collapses to "choose a `closeQty` per expiry" — the same shape as perps + — so the futures and perps solvers unify, and the canonical order is a clean + ranking over a handful of net positions. +- It also shrinks the margin/PnL bookkeeping the PME and the guard walk over, + reducing the per-liquidation gas independent of the verification question. + +In short: **lot aggregation (one net position per expiration date) is the +prerequisite that turns the deterministic, verifiable rule set from `O(#lots)` +and discrete into `O(#expiries)` and continuous** — and it is the change to make +before investing in strict on-chain prefix verification or the router. The +maintenance-margin math and cross-margin offsets are unaffected (they already +operate on net delta / net exposure); what changes is that the *unit of +liquidation* becomes the per-expiry net position rather than the individual lot. diff --git a/keeper/src/config.ts b/keeper/src/config.ts index eaaa3e3..edd1dc3 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -67,6 +67,17 @@ export interface Config { address: Address; /** Optional fast pre-filter (token decimals). */ minNotional?: bigint; + /** + * Max futures lots closed per `liquidatePositions` tx (gas-bounded + * chunking, "Option A"). `reduceToTarget` sends ONE worst-first chunk of + * at most this many lots; the planner loop re-invokes it (re-snapshotting + * each time) until the account is healthy. Each `_liquidateOnePosition` is + * roughly 150-250k gas, so 50 keeps a full chunk (~12M) well under Base's + * 30M block limit. Lower it for chains with tighter blocks or unusually + * expensive settlement paths. Must stay ≤ `MAX_POSITION_ITERATIONS × this` + * worth of headroom for the largest realistic single-user book. + */ + maxLotsPerLiquidationTx: number; }; pme: { address: Address }; oracle: { @@ -360,6 +371,9 @@ export function loadConfig(): Config { futures: { address: requireAddress("FUTURES_ADDRESS"), minNotional: optionalBigInt("FUTURES_MIN_NOTIONAL"), + maxLotsPerLiquidationTx: Number( + process.env.FUTURES_MAX_LOTS_PER_LIQUIDATION_TX ?? "50", + ), }, pme: { address: requireAddress("PME_ADDRESS") }, oracle: { diff --git a/keeper/src/coordinator/planner.ts b/keeper/src/coordinator/planner.ts index 98840ef..cf3febf 100644 --- a/keeper/src/coordinator/planner.ts +++ b/keeper/src/coordinator/planner.ts @@ -1,7 +1,7 @@ -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; import type pino from "pino"; import type { Chain } from "../chain.ts"; -import type { Venue, VenuePosition } from "../venues/types.ts"; +import type { Venue } from "../venues/types.ts"; import type { Config } from "../config.ts"; import type { AccountHealth } from "../pme/health.ts"; import { readAccountHealthBatch } from "../pme/health.ts"; @@ -25,7 +25,7 @@ interface StepReport { venue: Venue["name"]; feeEarned: bigint; ordersClosed?: number; - positionId?: Hex; + positionsClosed?: number; skipped?: string; } @@ -39,14 +39,16 @@ interface StepReport { * 2. If `mmSurplus >= 0`: account is healthy — emit `done`. * 3. Else: call `liquidateOrders` on every venue that has open orders. * Re-snapshot health. - * 4. If still unhealthy: pick the most-underwater single position across - * all venues (max `unrealizedLoss`, tiebreak on `notional`) and call - * `liquidatePosition` on that venue. The on-chain `OrdersStillOpen` - * revert is treated as a recoverable race — re-run step 3 then retry. - * Re-snapshot health. - * 5. Repeat step 4 until healthy OR no positions remain. If no positions - * remain and the account is still unhealthy, emit a `BadDebt` log and - * a critical alert (the insurance fund must absorb the residual). + * 4. If still unhealthy: pick the most-underwater venue (max summed + * `unrealizedLoss` across its positions) and call `reduceToTarget(user)` + * — ONE batched tx that closes the venue's worst-first positions down to + * the IM buffer (futures: a lot subset; perps: a partial `closeQty`). The + * on-chain `OrdersStillOpen` revert is treated as a recoverable race — + * re-run step 3 then retry. Re-snapshot health. + * 5. Repeat step 4 until healthy OR no venue can close any more (all + * positions gone, or every venue reports `nothingToClose`). If positions + * are gone and the account is still unhealthy, emit a `BadDebt` log and a + * critical alert (the insurance fund must absorb the residual). * * The planner is purely orchestration — venues encapsulate calldata, * Multicall3 batching, gas estimation, and the unprofitable / not-liquidatable @@ -54,14 +56,17 @@ interface StepReport { */ export class Planner { /** - * Hard cap on the position-leg loop. Each iteration closes at least one - * position OR retries an `ordersLeg` after an `OrdersStillOpen` race — - * looping forever shouldn't be possible, but this is a defense-in-depth - * cap so a venue bug can't pin the executor on one user. Set generously: - * 16 iterations × ~50 positions per venue ≈ 800 closures, far above any - * realistic single-user portfolio. + * Hard cap on the position-leg loop. Each iteration issues ONE gas-bounded + * `reduceToTarget` chunk (Futures closes up to `maxLotsPerLiquidationTx` + * lots; Perps closes any quantity in one tx) OR retries an `ordersLeg` after + * an `OrdersStillOpen` race. With chunking, a large book drains across + * SUCCESSIVE iterations, so this must be generous enough to cover + * `ceil(largestBook / chunkSize)` per venue plus a few order replays. + * 64 iterations × ~50 lots per Futures chunk ≈ 3,200 lot closures — far + * above any realistic single-user portfolio — while still being a firm + * defense-in-depth cap so a venue bug can't pin the executor on one user. */ - private static readonly MAX_POSITION_ITERATIONS = 16; + private static readonly MAX_POSITION_ITERATIONS = 64; // Explicit fields — Node's TypeScript strip-only mode does not support // parameter properties (the `private readonly chain: Chain` shortcut). @@ -127,41 +132,54 @@ export class Planner { }; } - // Step 4–5: position loop, one position at a time, picking the worst - // across all venues. We re-snapshot health after every closure since - // closing one position can flip the account healthy or change the - // ranking of the remaining positions. + // Step 4–5: position loop. Each iteration picks the most-underwater venue + // and issues ONE batched `reduceToTarget` that closes it down to the IM + // buffer, then re-snapshots health. Venues that report `nothingToClose` + // (their leg is already at/above IM but the portfolio is still under MM) + // are parked in `exhausted` so we don't spin on them. + const exhausted = new Set(); for (let iter = 0; iter < Planner.MAX_POSITION_ITERATIONS; iter++) { - const ranked = await this.rankPositions(user); - if (ranked.length === 0) { - // No positions left to close but still unhealthy → bad debt. + const rankedVenues = await this.rankVenuesByLoss(user); + const actionable = rankedVenues.filter((v) => !exhausted.has(v.venue.name)); + + if (rankedVenues.length === 0) { + // No positions left to close anywhere but still unhealthy → bad debt. log.error( { mmSurplus: health.mmSurplus, totalFee, positionsClosed, ordersClosed }, "BadDebt: no positions remain but account still under MM", ); return { kind: "badDebt", mmSurplus: health.mmSurplus, feeEarned: totalFee }; } + if (actionable.length === 0) { + // Every venue with positions reported `nothingToClose` — the account + // is under MM on-chain but no venue's off-chain sizing found a close + // (a snapshot/price race). Re-queue rather than force a full close. + log.warn( + { mmSurplus: health.mmSurplus, totalFee, positionsClosed, ordersClosed }, + "Position-leg: all venues report nothingToClose — stalling", + ); + return { kind: "stalled", reason: "nothingToClose", mmSurplus: health.mmSurplus }; + } - const worst = ranked[0]; + const worst = actionable[0]; log.info( { venue: worst.venue.name, - marketLabel: worst.venue.marketLabel(worst.position.marketId), - unrealizedLoss: worst.position.unrealizedLoss, - notional: worst.position.notional, + unrealizedLoss: worst.totalLoss, + positionCount: worst.positionCount, }, - "Position-leg: liquidating worst position", + "Position-leg: reducing worst venue down to the IM buffer", ); - const result = await worst.venue.liquidatePosition(user, worst.position.id); + const result = await worst.venue.reduceToTarget(user); if ("feeEarned" in result) { - positionsClosed++; + positionsClosed += result.positionsClosed; totalFee += result.feeEarned; reports.push({ kind: "positionLeg", venue: worst.venue.name, feeEarned: result.feeEarned, - positionId: worst.position.id, + positionsClosed: result.positionsClosed, }); } else if (result.skipped === "ordersStillOpen") { // A new order appeared between the orders-leg and now (race with the @@ -178,24 +196,15 @@ export class Planner { ordersClosed += r.ordersClosed ?? 0; } } else { - // Either `notLiquidatable` (this position is no longer liquidatable - // — likely already-closed; loop to re-rank), or `unprofitable` (gas - // cost exceeds reward — bail rather than burn money). + // `nothingToClose` (park the venue) or `notLiquidatable` (stale + // snapshot / `OverLiquidation` race — re-rank from a fresh snapshot). reports.push({ kind: "positionLeg", venue: worst.venue.name, feeEarned: 0n, - positionId: worst.position.id, skipped: result.skipped, }); - if (result.skipped === "unprofitable") { - log.warn( - { mmSurplus: health.mmSurplus, totalFee, positionsClosed }, - "Position-leg unprofitable — stalling", - ); - return { kind: "stalled", reason: "unprofitable", mmSurplus: health.mmSurplus }; - } - // notLiquidatable → loop and re-rank from a fresh snapshot. + if (result.skipped === "nothingToClose") exhausted.add(worst.venue.name); } health = await this.readHealth(user); @@ -275,28 +284,38 @@ export class Planner { } /** - * Returns every (venue, position) pair across all venues, sorted - * most-underwater first. Primary key is `unrealizedLoss` DESC; tiebreak is - * `notional` DESC (closing the bigger position frees more margin). + * Ranks venues that hold at least one position for `user`, most-underwater + * first. Per venue we sum `unrealizedLoss` across its positions (primary key + * DESC); tiebreak is summed `notional` DESC (the bigger book frees more + * margin when reduced). Venues with no positions are omitted — the position + * leg only ever calls `reduceToTarget` on venues that have something to close. */ - private async rankPositions( + private async rankVenuesByLoss( user: Address, - ): Promise> { - const all: Array<{ venue: Venue; position: VenuePosition }> = []; + ): Promise> { + const ranked: Array<{ + venue: Venue; + totalLoss: bigint; + totalNotional: bigint; + positionCount: number; + }> = []; for (const venue of this.venues) { const positions = await venue.readPositions(user); - for (const p of positions) all.push({ venue, position: p }); - } - all.sort((a, b) => { - if (a.position.unrealizedLoss !== b.position.unrealizedLoss) { - return a.position.unrealizedLoss < b.position.unrealizedLoss ? 1 : -1; - } - if (a.position.notional !== b.position.notional) { - return a.position.notional < b.position.notional ? 1 : -1; + if (positions.length === 0) continue; + let totalLoss = 0n; + let totalNotional = 0n; + for (const p of positions) { + totalLoss += p.unrealizedLoss; + totalNotional += p.notional; } + ranked.push({ venue, totalLoss, totalNotional, positionCount: positions.length }); + } + ranked.sort((a, b) => { + if (a.totalLoss !== b.totalLoss) return a.totalLoss < b.totalLoss ? 1 : -1; + if (a.totalNotional !== b.totalNotional) return a.totalNotional < b.totalNotional ? 1 : -1; return 0; }); - return all; + return ranked; } private async readHealth(user: Address): Promise { diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 889906d..4afbcbd 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -145,6 +145,7 @@ export async function readAccountSnapshot( id, isBuyer, entryPricePerDay: isBuyer ? pos.buyPricePerDay : pos.sellPricePerDay, + deliveryAt: pos.deliveryAt, }); } } diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index cd3e673..17c3abe 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -1,5 +1,6 @@ +import type { Hex } from "viem"; import type { AccountSnapshot, AlertThresholds, MMParams, PriceThresholds } from "./types.ts"; -import { imRequired, mmSurplus } from "./mm.ts"; +import { imRequired, imSurplus, mmSurplus } from "./mm.ts"; /** * Find the price thresholds where `mmSurplus(P)` crosses zero. @@ -167,6 +168,288 @@ function findClosestCrossings( return { down, up }; } +// ─────────────────────────────────────────────────────────────────────────── +// Close-to-IM-buffer sizing (the batched-liquidation solvers) +// +// The on-chain `liquidatePositions` (futures) / `liquidatePosition(user, +// closeQty)` (perps) do NOT recompute margin per lot — they close the +// keeper-supplied amount and enforce a single end-of-tx `OverLiquidation` +// guard: with positions remaining and a real IM buffer (`im > mm`), the +// leftover balance must sit at/under IM. These solvers pick, off-chain, the +// deepest close that keeps the account inside the `[MM, IM]` band (healthy but +// not over-liquidated) — so one batched tx replaces the old one-lot-per-tx +// churn. If no in-band partial exists (deep crash / bad debt) they fall back +// to a full close, which the contract lets through (the guard is skipped once +// no positions remain). +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Off-chain replica of the futures batch close: remove `closeIds` from the + * snapshot and debit the realized PnL + flat fee of each closed lot from the + * balance. Mirrors `Futures._forceLiquidatePosition` (loss/profit routed + * through the insurance fund) + the per-lot `liquidationFee`. Entry prices of + * the surviving lots are untouched. Shared by the solver and its tests so the + * band predicate they assert is the exact one the solver optimises against. + */ +export function simulateFuturesClose( + snap: AccountSnapshot, + closeIds: readonly Hex[], + currentPrice: bigint, + liquidationFee: bigint, +): AccountSnapshot { + const closeSet = new Set(closeIds); + const remaining: AccountSnapshot["futures"]["positions"] = []; + let balanceDelta = 0n; + for (const pos of snap.futures.positions) { + if (!closeSet.has(pos.id)) { + remaining.push(pos); + continue; + } + const diffPerDay = pos.isBuyer + ? currentPrice - pos.entryPricePerDay + : pos.entryPricePerDay - currentPrice; + const pnl = diffPerDay * snap.futures.deliveryDays; + balanceDelta += pnl - liquidationFee; + } + return { + ...snap, + balance: snap.balance + balanceDelta, + futures: { ...snap.futures, positions: remaining }, + }; +} + +/** + * Off-chain replica of the perps partial close: reduce `netQty` toward zero by + * `min(closeQty, |netQty|)` and debit the realized PnL on that slice plus the + * single flat fee. Mirrors `HashPowerPerpsDEX._doPartialLiquidatePosition` + * (`_settleReducedPosition` + one `liquidationFee`). Entry price unchanged. + */ +export function simulatePerpClose( + snap: AccountSnapshot, + closeQty: bigint, + currentPrice: bigint, + liquidationFee: bigint, +): AccountSnapshot { + const netQty = snap.perp.netQty; + const absNet = netQty < 0n ? -netQty : netQty; + const closeAbs = closeQty < absNet ? closeQty : absNet; + if (closeAbs <= 0n) return snap; + + const isLong = netQty > 0n; + const signedClose = isLong ? closeAbs : -closeAbs; + // Perps quantities are scaled by 10^QUANTITY_DECIMALS (=6 in HashPowerPerpsDEX); + // matches `perpUnrealizedLoss` in mm.ts and the venue's QUANTITY_SCALE. + const qtyScale = 10n ** 6n; + const pnl = ((currentPrice - snap.perp.entryPrice) * signedClose) / qtyScale; + const newNetQty = isLong ? netQty - closeAbs : netQty + closeAbs; + return { + ...snap, + balance: snap.balance + pnl - liquidationFee, + perp: { ...snap.perp, netQty: newNetQty }, + }; +} + +/** + * Pick the worst-first subset of futures lot ids to close so the account lands + * inside the `[MM, IM]` band. Lots are ranked by unrealized loss (desc), then + * notional (desc). We add lots one at a time (simulating each removal) and keep + * the DEEPEST prefix that is healthy at MM while staying at/under IM. When a + * real IM buffer exists (`imSpotShock > mmSpotShock`), closing past the IM + * crossing would trip `OverLiquidation`, so we stop there. In the degenerate + * `IM == MM` case there is no upper bound — we take the minimal healthy prefix. + * Returns `[]` if already healthy, or every id (full close) when no in-band + * partial exists (deep crash / bad debt — the contract skips the guard once + * the position set is empty). + */ +export function solveFuturesLotsToTarget( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + liquidationFee: bigint, +): Hex[] { + const positions = snap.futures.positions; + if (positions.length === 0) return []; + if (mmSurplus(snap, params, currentPrice) >= 0n) return []; + + const hasBuffer = params.imSpotShock > params.mmSpotShock; + + // Expiry-balanced worst-first ordering. Each `deliveryAt` is a separate + // market/order-book, so we interleave closures across expirations (round + // robin, worst-first within each) instead of a single global worst-first + // prefix that would drain one expiry's book before touching another. The + // batch is still submitted in one `liquidatePositions` tx; balancing only + // shapes WHICH lots that tx closes. The prefix search below is unchanged, so + // we still stop at the deepest in-band subset (reaching IM stays the + // priority — balance is best-effort within that). + const ranked = rankLotsBalancedAcrossExpirations( + positions, + snap.futures.deliveryDays, + currentPrice, + ); + + const n = ranked.length; + let best: Hex[] | undefined; + for (let k = 1; k < n; k++) { + const closeSet = ranked.slice(0, k).map((p) => p.id); + const after = simulateFuturesClose(snap, closeSet, currentPrice, liquidationFee); + const mmS = mmSurplus(after, params, currentPrice); + const imS = imSurplus(after, params, currentPrice); + if (!hasBuffer) { + // Degenerate IM == MM: no over-liquidation ceiling. Take minimal healthy. + if (mmS >= 0n) { + best = closeSet; + break; + } + continue; + } + if (mmS >= 0n && imS <= 0n) best = closeSet; // in band — record and keep going deeper + if (imS > 0n) break; // deeper only raises IM surplus → would over-liquidate + } + + if (best !== undefined) return best; + // No in-band partial — close everything (bad-debt / full-deleverage path). + return ranked.map((p) => p.id); +} + +/** + * Pick the absolute `closeQty` (scaled by perp quantity decimals) to partially + * close a perps position down into the `[MM, IM]` band. `mmSurplus` and + * `imSurplus` are both monotone increasing in the closed quantity, so we + * bisect: with a real IM buffer we take the deepest close that stays at/under + * IM (which is automatically ≥ the minimal-healthy amount); degenerate + * `IM == MM` targets minimal-healthy. Returns `0n` if already healthy, or + * `|netQty|` (full close) when even closing everything can't reach the band + * (deep crash / bad debt). + */ +export function solvePerpCloseToTarget( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + liquidationFee: bigint, +): bigint { + const netQty = snap.perp.netQty; + const absNet = netQty < 0n ? -netQty : netQty; + if (absNet === 0n) return 0n; + if (mmSurplus(snap, params, currentPrice) >= 0n) return 0n; + + const mmS = (q: bigint) => + mmSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); + const imS = (q: bigint) => + imSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); + + const hasBuffer = params.imSpotShock > params.mmSpotShock; + + if (!hasBuffer) { + // Minimal healthy close; if even a full close can't heal, full close. + if (mmS(absNet) < 0n) return absNet; + const qHealthy = firstQtyWhere(mmS, absNet); + return qHealthy >= absNet ? absNet : qHealthy; + } + + // If even a full close leaves the account under MM, it's bad debt — close all. + if (mmS(absNet) < 0n) return absNet; + + // Deepest close that stays at/under IM = (first q where imSurplus > 0) − 1. + // If IM surplus never turns positive before a full close, the whole position + // is bad-debt-adjacent → full close. + if (imS(absNet) <= 0n) return absNet; + const qOverIM = firstQtyWhere((q) => (imS(q) > 0n ? 1n : -1n), absNet); + const qStar = qOverIM - 1n; + return qStar >= absNet ? absNet : qStar < 0n ? 0n : qStar; +} + +/** + * Smallest `q` in `[0, hi]` at which the monotone-increasing `f(q)` becomes + * `>= 0`. Assumes `f(0) < 0` and `f(hi) >= 0` (callers guarantee this via the + * healthy / bad-debt short-circuits). Bisection in scaled quantity units. + */ +function firstQtyWhere(f: (q: bigint) => bigint, hi: bigint): bigint { + let a = 0n; + let b = hi; + if (f(b) < 0n) return hi; + if (f(a) >= 0n) return 0n; + while (b - a > 1n) { + const m = (a + b) / 2n; + if (f(m) >= 0n) b = m; + else a = m; + } + return b; +} + +type FuturesLot = AccountSnapshot["futures"]["positions"][number]; + +/** + * Order futures lots so a worst-first prefix is *balanced across expirations*. + * + * Lots are grouped by `deliveryAt` (each group = one market). Within a group + * they are sorted worst-first (unrealized loss desc, then notional desc, then + * id for determinism). Groups are then round-robin interleaved — round `r` + * takes the r-th lot of every group that still has one — with groups visited + * worst-first (group total loss desc, tiebreak `deliveryAt` asc). + * + * The effect: any prefix of the result draws from every expiry evenly until a + * book is exhausted, so the deepest in-band prefix spreads the close rather + * than emptying a single expiry's book. A single-expiry portfolio collapses to + * plain worst-first (identical to the pre-balancing behaviour). + */ +function rankLotsBalancedAcrossExpirations( + positions: readonly FuturesLot[], + deliveryDays: bigint, + currentPrice: bigint, +): FuturesLot[] { + const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, deliveryDays, currentPrice); + const notionalOf = (p: FuturesLot) => p.entryPricePerDay * deliveryDays; + + const groups = new Map(); + for (const p of positions) { + const bucket = groups.get(p.deliveryAt); + if (bucket === undefined) groups.set(p.deliveryAt, [p]); + else bucket.push(p); + } + + const worstFirst = (a: FuturesLot, b: FuturesLot): number => { + const la = lossOf(a); + const lb = lossOf(b); + if (la !== lb) return la < lb ? 1 : -1; + const na = notionalOf(a); + const nb = notionalOf(b); + if (na !== nb) return na < nb ? 1 : -1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; + for (const bucket of groups.values()) bucket.sort(worstFirst); + + const orderedGroups = [...groups.entries()] + .sort(([dateA, groupA], [dateB, groupB]) => { + const lossA = groupA.reduce((s, p) => s + lossOf(p), 0n); + const lossB = groupB.reduce((s, p) => s + lossOf(p), 0n); + if (lossA !== lossB) return lossA < lossB ? 1 : -1; + return dateA < dateB ? -1 : dateA > dateB ? 1 : 0; + }) + .map(([, group]) => group); + + const result: FuturesLot[] = []; + let maxLen = 0; + for (const group of orderedGroups) if (group.length > maxLen) maxLen = group.length; + for (let round = 0; round < maxLen; round++) { + for (const group of orderedGroups) { + const lot = group[round]; + if (lot !== undefined) result.push(lot); + } + } + return result; +} + +/** Per-lot unrealized loss at `P` (token decimals); 0 when in profit. */ +function lotUnrealizedLoss( + pos: AccountSnapshot["futures"]["positions"][number], + deliveryDays: bigint, + P: bigint, +): bigint { + const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; + const pnl = diffPerDay * deliveryDays; + return pnl < 0n ? -pnl : 0n; +} + /** Bisect within [lo, hi] until the interval shrinks to 1 wei. Assumes a sign change. */ function bisect( lo: bigint, diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts index 88fb9f3..00a5537 100644 --- a/keeper/src/predict/types.ts +++ b/keeper/src/predict/types.ts @@ -41,6 +41,15 @@ export interface AccountSnapshot { isBuyer: boolean; /** Token decimals. */ entryPricePerDay: bigint; + /** + * Expiration timestamp (unix seconds) this lot delivers at. Lots sharing + * a `deliveryAt` are the same market/order-book; the liquidation solver + * groups on it to balance closures across expirations rather than + * draining one expiry's book. It does NOT affect PnL/margin math — every + * lot is valued with the single global `deliveryDays` (mirroring the + * on-chain `deliveryDurationDays`). + */ + deliveryAt: bigint; }>; /** Constant in P: `getFuturesOrderMargin(user)`. */ orderMargin: bigint; diff --git a/keeper/src/tx/liquidate.ts b/keeper/src/tx/liquidate.ts index 328640b..2c74e5d 100644 --- a/keeper/src/tx/liquidate.ts +++ b/keeper/src/tx/liquidate.ts @@ -27,6 +27,7 @@ export type LiquidateOutcome = type KnownRevert = | "NotLiquidatable" | "OrdersStillOpen" + | "OverLiquidation" | "OrderNotBelongToUser" | "OrderNotBelongToParticipant" | "PositionNotBelongToParticipant" @@ -35,6 +36,10 @@ type KnownRevert = const RECOVERABLE_REVERTS = new Set([ "NotLiquidatable", "OrdersStillOpen", + // A mis-sized batch (off-chain snapshot raced a price move) that overshoots + // the IM buffer reverts `OverLiquidation` — recoverable: the planner + // re-snapshots and re-sizes on the next iteration rather than crashing. + "OverLiquidation", "OrderNotBelongToUser", "OrderNotBelongToParticipant", "PositionNotBelongToParticipant", diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 93d5b5b..cfbb9e7 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -4,11 +4,14 @@ import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; +import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; +import { solveFuturesLotsToTarget } from "../predict/solve.ts"; +import type { MMParams } from "../predict/types.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, - LiquidatePositionOutcome, MarketId, + ReduceToTargetOutcome, Venue, VenueOrder, VenuePosition, @@ -31,6 +34,7 @@ export class FuturesVenue implements Venue { private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; private deliveryDurationDays: bigint | undefined; + private mmParams: MMParams | undefined; constructor( chain: Chain, @@ -161,18 +165,56 @@ export class FuturesVenue implements Venue { : { feeEarned: result.feeEarned }; } - async liquidatePosition( - user: Address, - id: Hex, - ): Promise { + async reduceToTarget(user: Address): Promise { + // Size the worst-first lot subset off-chain against a fresh snapshot so the + // account lands inside the [MM, IM] band (or a full close on a deep crash). + const [snapshot, params, marketPrice] = await Promise.all([ + readAccountSnapshot(this.chain, this.config, user), + this.getMMParams(), + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getMarketPrice", + }) as Promise, + ]); + + // The contract's liquidation-fee payout is disabled, so each closed lot realizes + // no fee — pass 0 to the solver so its balance projection matches on-chain reality. + const ids = solveFuturesLotsToTarget(snapshot, params, marketPrice, 0n); + if (ids.length === 0) { + // Off-chain sizing says the account is already at/above the IM buffer. + return { skipped: "nothingToClose" }; + } + + // Gas-bounded chunking ("Option A"): send at most `maxLotsPerLiquidationTx` + // of the worst-first ids in this batch. `ids` is already ordered + // worst-first (highest unrealized loss), and a chunk shorter than the + // solver's full target closes FEWER lots than needed — so the leftover + // balance stays below IM and the on-chain `OverLiquidation` guard can't + // trip. The planner loop re-invokes `reduceToTarget` on a fresh snapshot to + // drain the remaining lots across successive txs (adapting to price drift). + const cap = this.config.futures.maxLotsPerLiquidationTx; + const chunk = cap > 0 && ids.length > cap ? ids.slice(0, cap) : ids; + + this.logger.info( + { + user, + lotsInChunk: chunk.length, + lotsToClose: ids.length, + ofTotal: snapshot.futures.positions.length, + chunked: chunk.length < ids.length, + }, + "Futures reduceToTarget: closing worst-first lot chunk in one batch", + ); + const result = await sendLiquidate({ chain: this.chain, config: this.config, logger: this.logger, address: this.config.futures.address, abi: FuturesAbi, - functionName: "liquidatePosition", - args: [user, id], + functionName: "liquidatePositions", + args: [user, chunk], feeEventName: "LotLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; @@ -183,7 +225,14 @@ export class FuturesVenue implements Venue { return "skipped" in result ? { skipped: result.skipped } - : { feeEarned: result.feeEarned }; + : { feeEarned: result.feeEarned, positionsClosed: chunk.length }; + } + + /** Read + cache the PME engine params (shocks / decimals). Immutable per epoch. */ + private async getMMParams(): Promise { + if (this.mmParams !== undefined) return this.mmParams; + this.mmParams = await readMMParams(this.chain, this.config); + return this.mmParams; } /** diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index c88b31f..d98d825 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -14,11 +14,14 @@ import type { Config } from "../config.ts"; import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import { formatGasCost } from "../tx/gasCost.ts"; +import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; +import { solvePerpCloseToTarget } from "../predict/solve.ts"; +import type { MMParams } from "../predict/types.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, - LiquidatePositionOutcome, MarketId, + ReduceToTargetOutcome, Venue, VenueOrder, VenuePosition, @@ -36,6 +39,7 @@ export class PerpsVenue implements Venue { private readonly config: Config; private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; + private mmParams: MMParams | undefined; constructor( chain: Chain, @@ -220,10 +224,32 @@ export class PerpsVenue implements Venue { return { feeEarned }; } - async liquidatePosition( - user: Address, - _id: Hex, - ): Promise { + async reduceToTarget(user: Address): Promise { + // Size the partial close off-chain against a fresh snapshot so the account + // lands inside the [MM, IM] band (or a full close on a deep crash). + const [snapshot, params, marketPrice] = await Promise.all([ + readAccountSnapshot(this.chain, this.config, user), + this.getMMParams(), + this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getMarketPrice", + }) as Promise, + ]); + + // The contract's liquidation-fee payout is disabled, so the close realizes no + // fee — pass 0 to the solver so its balance projection matches on-chain reality. + const closeQty = solvePerpCloseToTarget(snapshot, params, marketPrice, 0n); + if (closeQty === 0n) { + return { skipped: "nothingToClose" }; + } + + const absNet = snapshot.perp.netQty < 0n ? -snapshot.perp.netQty : snapshot.perp.netQty; + this.logger.info( + { user, closeQty, absNet, fullClose: closeQty >= absNet }, + "Perps reduceToTarget: partial close down to the IM buffer", + ); + const result = await sendLiquidate({ chain: this.chain, config: this.config, @@ -231,13 +257,13 @@ export class PerpsVenue implements Venue { address: this.config.perps.address, abi: HashPowerPerpsDEXAbi, functionName: "liquidatePosition", - args: [user], + args: [user, closeQty], feeEventName: "PositionLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; - // Both `NotLiquidatable` and any other recoverable revert collapse to - // `notLiquidatable` — the planner's recheck-then-retry loop handles - // it the same way. + // `NotLiquidatable` / `OverLiquidation` (a price race) and any other + // recoverable revert collapse to `notLiquidatable` — the planner's + // recheck-then-retry loop re-snapshots and re-sizes. return "notLiquidatable"; }, ethUsdFeed: this.ethUsdFeed, @@ -245,7 +271,14 @@ export class PerpsVenue implements Venue { return "skipped" in result ? { skipped: result.skipped } - : { feeEarned: result.feeEarned }; + : { feeEarned: result.feeEarned, positionsClosed: 1 }; + } + + /** Read + cache the PME engine params (shocks / decimals). Immutable per epoch. */ + private async getMMParams(): Promise { + if (this.mmParams !== undefined) return this.mmParams; + this.mmParams = await readMMParams(this.chain, this.config); + return this.mmParams; } } diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index 7c7497e..b28e95d 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -33,9 +33,23 @@ export type LiquidateOrdersOutcome = | { feeEarned: bigint } | { skipped: "notLiquidatable" }; -export type LiquidatePositionOutcome = - | { feeEarned: bigint } - | { skipped: "unprofitable" | "notLiquidatable" | "ordersStillOpen" }; +/** + * Result of a batched `reduceToTarget` call. + * + * - `feeEarned` / `positionsClosed`: the batch executed; `positionsClosed` + * is the number of lots (futures) or `1` (perps partial/full close) that + * closed, for planner telemetry. + * - `skipped`: + * - `nothingToClose` — the off-chain sizing found the account already + * at/above the IM buffer (no lots to close). + * - `notLiquidatable` — the venue's on-chain predicate rejected the batch + * (healthy, or a stale snapshot / `OverLiquidation` race). Planner + * re-snapshots and retries. + * - `ordersStillOpen` — resting orders must be cleared first. + */ +export type ReduceToTargetOutcome = + | { feeEarned: bigint; positionsClosed: number } + | { skipped: "nothingToClose" | "notLiquidatable" | "ordersStillOpen" }; /** * Cross-product abstraction the coordinator and planner consume. Each venue @@ -74,11 +88,20 @@ export interface Venue { liquidateOrders(user: Address, ids?: readonly Hex[]): Promise; /** - * Calls `liquidatePosition(user, id)` on the venue. `id` is unique within the - * venue across all markets. Reverts on-chain with `OrdersStillOpen` if any - * orders remain — the venue surface translates that into - * `{ skipped: "ordersStillOpen" }` so the coordinator can re-run - * `liquidateOrders` without crashing the plan. + * Liquidate `user`'s positions at this venue down to the IM buffer in a + * SINGLE batched transaction (the anti-churn "close-to-IM" path): + * + * 1. Read a fresh account snapshot + engine params. + * 2. Size the worst-first close off-chain so the account lands inside the + * `[MM, IM]` band (futures: a lot-id subset; perps: a partial + * `closeQty`). Deep-underwater accounts with no in-band partial size to + * a full close. + * 3. Submit ONE tx — futures `liquidatePositions(user, ids[])`, perps + * `liquidatePosition(user, closeQty)`. + * + * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first) or + * `OverLiquidation` (a price race made the sizing overshoot IM) are + * translated into `{ skipped }` so the planner re-plans without crashing. */ - liquidatePosition(user: Address, id: Hex): Promise; + reduceToTarget(user: Address): Promise; } diff --git a/keeper/tests/coordinator/planner.test.ts b/keeper/tests/coordinator/planner.test.ts index 70d82d1..f351ae2 100644 --- a/keeper/tests/coordinator/planner.test.ts +++ b/keeper/tests/coordinator/planner.test.ts @@ -7,7 +7,7 @@ import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; import type { LiquidateOrdersOutcome, - LiquidatePositionOutcome, + ReduceToTargetOutcome, Venue, VenueOrder, VenuePosition, @@ -31,7 +31,7 @@ const silentLogger = { interface FakeVenue extends Venue { // Counters for assertions: ordersCalls: number; - positionCalls: Array<{ id: Hex }>; + reduceCalls: number; } function makeFakeVenue(name: Venue["name"], opts: { @@ -39,23 +39,26 @@ function makeFakeVenue(name: Venue["name"], opts: { ordersByCall?: VenueOrder[][]; positionsByCall?: VenuePosition[][]; ordersOutcomeByCall?: LiquidateOrdersOutcome[]; - positionOutcomeByCall?: LiquidatePositionOutcome[]; + reduceOutcomeByCall?: ReduceToTargetOutcome[]; }): FakeVenue { let openOrdersCall = 0; let positionsCall = 0; let liqOrdersCall = 0; - let liqPositionCall = 0; + let reduceCall = 0; const venue: FakeVenue = { name, ordersCalls: 0, - positionCalls: [], + reduceCalls: 0, marketLabel: () => `${name}-market`, async readOpenOrders(_user) { const list = opts.ordersByCall?.[openOrdersCall++] ?? []; return list; }, async readPositions(_user) { - const list = opts.positionsByCall?.[positionsCall++] ?? []; + // Positions are read once per rank pass; when the script runs out we + // repeat the last snapshot so ranking stays stable across extra passes. + const list = opts.positionsByCall?.[positionsCall] ?? opts.positionsByCall?.at(-1) ?? []; + positionsCall++; return list; }, async liquidateOrders(_user, _ids) { @@ -63,10 +66,10 @@ function makeFakeVenue(name: Venue["name"], opts: { const out = opts.ordersOutcomeByCall?.[liqOrdersCall++]; return out ?? { feeEarned: 0n }; }, - async liquidatePosition(_user, id) { - venue.positionCalls.push({ id }); - const out = opts.positionOutcomeByCall?.[liqPositionCall++]; - return out ?? { feeEarned: 0n }; + async reduceToTarget(_user) { + venue.reduceCalls++; + const out = opts.reduceOutcomeByCall?.[reduceCall++]; + return out ?? { feeEarned: 0n, positionsClosed: 1 }; }, }; void opts.marketId; // marketId is informational — used by readOpenOrders/readPositions inputs @@ -108,7 +111,7 @@ describe("Planner.run: healthy account on entry", () => { const outcome = await planner.run(USER); assert.equal(outcome.kind, "healthy"); assert.equal(venue.ordersCalls, 0, "no liquidate calls when healthy"); - assert.equal(venue.positionCalls.length, 0); + assert.equal(venue.reduceCalls, 0); }); }); @@ -135,7 +138,7 @@ describe("Planner.run: orders-leg only", () => { assert.equal(outcome.positionsClosed, 0); } assert.equal(venue.ordersCalls, 1); - assert.equal(venue.positionCalls.length, 0); + assert.equal(venue.reduceCalls, 0); }); it("skips a venue's liquidateOrders call when readOpenOrders returns empty", async () => { @@ -181,7 +184,7 @@ describe("Planner.run: orders-leg only", () => { }); describe("Planner.run: position-leg ranking and execution", () => { - it("targets the most-underwater position across venues (max unrealizedLoss)", async () => { + it("reduces the most-underwater venue first (max summed unrealizedLoss)", async () => { const chain = makeChainStub([ { balance: 1000n, im: 950n, mm: 1200n }, // entry: under { balance: 1000n, im: 800n, mm: 1100n }, // after orders-leg: still under @@ -198,25 +201,62 @@ describe("Planner.run: position-leg ranking and execution", () => { const futures = makeFakeVenue("futures", { marketId: MARKET_FUT_A, ordersByCall: [[]], - // Heavy loss → must be picked first. + // Heavy loss → must be reduced first, and one batched call heals the account. positionsByCall: [[{ id: heavyPosId, marketId: MARKET_FUT_A, unrealizedLoss: 500n, notional: 2000n }]], - positionOutcomeByCall: [{ feeEarned: 12n }], + reduceOutcomeByCall: [{ feeEarned: 12n, positionsClosed: 3 }], }); const planner = new Planner(chain, makeConfigStub(), [perps, futures], silentLogger); const outcome = await planner.run(USER); - assert.equal(perps.positionCalls.length, 0, "perps light position never touched"); - assert.deepEqual(futures.positionCalls.map((c) => c.id), [heavyPosId]); + assert.equal(perps.reduceCalls, 0, "perps light book never touched"); + assert.equal(futures.reduceCalls, 1, "futures heavy book reduced once"); if (outcome.kind === "liquidated") { - assert.equal(outcome.positionsClosed, 1); + assert.equal(outcome.positionsClosed, 3, "batched close reports its lot count"); assert.equal(outcome.feeEarned, 12n); } else { assert.fail(`expected liquidated, got ${outcome.kind}`); } }); - it("tiebreaks equal unrealizedLoss by larger notional", async () => { + it("drains a gas-chunked book across successive reduceToTarget iterations", async () => { + // Futures venue returns one worst-first CHUNK per call (gas-bounded), each + // reporting partial progress while the account stays under MM, until the + // final chunk restores health. The planner must loop, re-snapshot, and sum + // the per-chunk lot counts + fees. + const chain = makeChainStub([ + { balance: 1000n, im: 950n, mm: 1200n }, // entry under + { balance: 1000n, im: 950n, mm: 1200n }, // after orders-leg still under + { balance: 1000n, im: 950n, mm: 1200n }, // after chunk #1 still under + { balance: 1000n, im: 950n, mm: 1200n }, // after chunk #2 still under + { balance: 1000n, im: 600n, mm: 800n }, // after chunk #3 healthy + ]); + const posId: Hex = "0x" + "77".repeat(32) as Hex; + const futures = makeFakeVenue("futures", { + marketId: MARKET_FUT_A, + ordersByCall: [[]], + // Positions still present through the run (FakeVenue repeats the last + // snapshot), so the venue stays actionable across all three chunks. + positionsByCall: [[{ id: posId, marketId: MARKET_FUT_A, unrealizedLoss: 500n, notional: 5000n }]], + reduceOutcomeByCall: [ + { feeEarned: 1n, positionsClosed: 50 }, + { feeEarned: 1n, positionsClosed: 50 }, + { feeEarned: 1n, positionsClosed: 20 }, + ], + }); + const planner = new Planner(chain, makeConfigStub(), [futures], silentLogger); + const outcome = await planner.run(USER); + + assert.equal(futures.reduceCalls, 3, "one reduceToTarget per gas-bounded chunk"); + if (outcome.kind === "liquidated") { + assert.equal(outcome.positionsClosed, 120, "summed lot count across the three chunks"); + assert.equal(outcome.feeEarned, 3n, "summed fees across the three chunks"); + } else { + assert.fail(`expected liquidated, got ${outcome.kind}`); + } + }); + + it("tiebreaks equal summed unrealizedLoss by larger notional venue", async () => { const chain = makeChainStub([ { balance: 1000n, im: 950n, mm: 1100n }, { balance: 1000n, im: 950n, mm: 1100n }, // still under after orders-leg @@ -224,26 +264,29 @@ describe("Planner.run: position-leg ranking and execution", () => { ]); const smallId: Hex = "0x" + "0a".repeat(32) as Hex; const bigId: Hex = "0x" + "0b".repeat(32) as Hex; - const venue = makeFakeVenue("perps", { + const perps = makeFakeVenue("perps", { marketId: MARKET_PERPS, ordersByCall: [[]], - positionsByCall: [[ - { id: smallId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 500n }, - { id: bigId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 5000n }, - ]], - positionOutcomeByCall: [{ feeEarned: 2n }], + positionsByCall: [[{ id: smallId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 500n }]], }); - const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const futures = makeFakeVenue("futures", { + marketId: MARKET_FUT_A, + ordersByCall: [[]], + positionsByCall: [[{ id: bigId, marketId: MARKET_FUT_A, unrealizedLoss: 100n, notional: 5000n }]], + reduceOutcomeByCall: [{ feeEarned: 2n, positionsClosed: 1 }], + }); + const planner = new Planner(chain, makeConfigStub(), [perps, futures], silentLogger); await planner.run(USER); - assert.deepEqual(venue.positionCalls.map((c) => c.id), [bigId]); + assert.equal(futures.reduceCalls, 1, "bigger-notional venue reduced first"); + assert.equal(perps.reduceCalls, 0); }); it("on OrdersStillOpen, replays orders-leg and retries on the next iteration", async () => { // Sequence of health snapshots: // 1. entry — under // 2. after 1st orders-leg — still under - // 3. after stale position attempt — still under (no-op since revert) - // 4. after replayed orders-leg + 2nd position-leg attempt — healthy + // 3. after stale reduce attempt — still under (no-op since revert) + // 4. after replayed orders-leg + 2nd reduce attempt — healthy const chain = makeChainStub([ { balance: 1000n, im: 950n, mm: 1100n }, { balance: 1000n, im: 950n, mm: 1100n }, @@ -254,7 +297,7 @@ describe("Planner.run: position-leg ranking and execution", () => { const replayedOrderId: Hex = "0x" + "44".repeat(32) as Hex; const venue = makeFakeVenue("perps", { marketId: MARKET_PERPS, - // orders-leg #1 (initial), rank #1, replayed orders-leg, rank #2 + // orders-leg #1 (initial, empty), then race-injected order for the replay. ordersByCall: [ [], // initial: no open orders [{ id: replayedOrderId, marketId: MARKET_PERPS }], // race-injected @@ -264,15 +307,15 @@ describe("Planner.run: position-leg ranking and execution", () => { [{ id: positionId, marketId: MARKET_PERPS, unrealizedLoss: 200n, notional: 1000n }], [{ id: positionId, marketId: MARKET_PERPS, unrealizedLoss: 200n, notional: 1000n }], ], - positionOutcomeByCall: [ + reduceOutcomeByCall: [ { skipped: "ordersStillOpen" }, - { feeEarned: 7n }, + { feeEarned: 7n, positionsClosed: 1 }, ], }); const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); const outcome = await planner.run(USER); - assert.equal(venue.positionCalls.length, 2, "retried position-leg after orders replay"); + assert.equal(venue.reduceCalls, 2, "retried reduceToTarget after orders replay"); assert.equal(venue.ordersCalls, 1, "only the replayed orders-leg called liquidateOrders (initial was empty)"); if (outcome.kind === "liquidated") { assert.equal(outcome.positionsClosed, 1); @@ -283,24 +326,26 @@ describe("Planner.run: position-leg ranking and execution", () => { } }); - it("returns `stalled: unprofitable` when the worst position is unprofitable", async () => { + it("returns `stalled: nothingToClose` when every venue can't size a close", async () => { const chain = makeChainStub([ - { balance: 1000n, im: 950n, mm: 1100n }, - { balance: 1000n, im: 950n, mm: 1100n }, + { balance: 1000n, im: 950n, mm: 1100n }, // entry under + { balance: 1000n, im: 950n, mm: 1100n }, // after orders-leg still under + { balance: 1000n, im: 950n, mm: 1100n }, // after parked reduce still under ]); const id: Hex = "0x" + "55".repeat(32) as Hex; const venue = makeFakeVenue("perps", { marketId: MARKET_PERPS, ordersByCall: [[]], positionsByCall: [[{ id, marketId: MARKET_PERPS, unrealizedLoss: 50n, notional: 100n }]], - positionOutcomeByCall: [{ skipped: "unprofitable" }], + reduceOutcomeByCall: [{ skipped: "nothingToClose" }], }); const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); const outcome = await planner.run(USER); assert.equal(outcome.kind, "stalled"); if (outcome.kind === "stalled") { - assert.equal(outcome.reason, "unprofitable"); + assert.equal(outcome.reason, "nothingToClose"); } + assert.equal(venue.reduceCalls, 1, "parked after one nothingToClose"); }); it("returns `badDebt` when no positions remain but mmSurplus stays negative", async () => { diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 66733c5..2da307e 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -216,7 +216,7 @@ function buildConfig( }, vault: { address: stack.addresses.vault }, perps: { address: stack.addresses.perps }, - futures: { address: stack.addresses.futures }, + futures: { address: stack.addresses.futures, maxLotsPerLiquidationTx: 50 }, pme: { address: stack.addresses.pme }, oracle: { hashpriceUsdcAddress: stack.addresses.hashpriceOracle, diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 0768a92..2719c58 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -112,6 +112,31 @@ export async function readFuturesPositionIds( })) as readonly Hex[]; } +/** + * Reads the `deliveryAt` (expiration timestamp) of each supplied futures lot + * id via `getPositionById`. Must be called *before* the lots are liquidated — + * the contract deletes a position from storage on close, so the mapping has to + * be snapshotted while every lot is still alive. Used by the multi-expiry + * balancing test to attribute each closed lot back to its book. + */ +export async function readFuturesLotExpiries( + stack: DeployedStack, + ids: readonly Hex[], +): Promise> { + const entries = await Promise.all( + ids.map(async (id) => { + const pos = (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getPositionById", + args: [id], + })) as { deliveryAt: bigint }; + return [id, pos.deliveryAt] as const; + }), + ); + return new Map(entries); +} + export async function readFuturesOrderIds( stack: DeployedStack, user: Address, @@ -142,6 +167,126 @@ export async function expectFuturesClosed( await waitFor(async () => (await readFuturesPositionIds(stack, user)).length === 0, timeoutMs); } +export interface AccountMargins { + balance: bigint; + imRequired: bigint; + mmRequired: bigint; +} + +/** + * Reads `(balanceOf, computePortfolioIM, computePortfolioMM)` for `user` — the + * on-chain source of truth the liquidation predicates (and the new + * `liquidatePositions` / partial-perps `OverLiquidation` guard) resolve back + * to. Used by `expectReducedToImBuffer` to assert the account landed inside the + * `[MM, IM]` band after a batched liquidation. Uses three parallel + * `readContract` calls (the test's public client has no multicall3 configured, + * matching every other reader in this file). + */ +export async function readAccountMargins( + stack: DeployedStack, + user: Address, +): Promise { + const [balance, imRequired, mmRequired] = await Promise.all([ + stack.publicClient.readContract({ + address: stack.addresses.vault, + abi: stack.abis.vault, + functionName: "balanceOf", + args: [user], + }) as Promise, + stack.publicClient.readContract({ + address: stack.addresses.pme, + abi: stack.abis.pme, + functionName: "computePortfolioIM", + args: [user], + }) as Promise, + stack.publicClient.readContract({ + address: stack.addresses.pme, + abi: stack.abis.pme, + functionName: "computePortfolioMM", + args: [user], + }) as Promise, + ]); + return { balance, imRequired, mmRequired }; +} + +/** + * Asserts the account was liquidated *down to the IM buffer* — i.e. it now + * sits inside the `[MM, IM]` band: + * + * - `balance >= computePortfolioMM(user)` → healthy (not re-liquidatable) + * - `balance <= computePortfolioIM(user)` → NOT over-liquidated (the + * contract's `OverLiquidation` guard tolerates landing at/under IM while + * positions remain; closing so much that balance exceeds IM would have + * reverted on-chain) + * + * Polls until the batched liquidation tx has confirmed (balance drops into or + * below the IM band) and then makes the hard band assertions with BigInt-safe + * diagnostics. This is the core acceptance predicate for the close-to-IM + * behaviour — a subset liquidation must leave the account healthy with a real + * buffer, not scraping the MM floor and not blown past IM. + */ +export async function expectReducedToImBuffer( + stack: DeployedStack, + user: Address, + timeoutMs = 30_000, +): Promise { + await waitFor(async () => { + const m = await readAccountMargins(stack, user); + return m.balance >= m.mmRequired && m.balance <= m.imRequired; + }, timeoutMs); + + const m = await readAccountMargins(stack, user); + assert.ok( + m.balance >= m.mmRequired, + `expected balance >= MM (healthy after liquidation), got balance=${m.balance}n mm=${m.mmRequired}n`, + ); + assert.ok( + m.balance <= m.imRequired, + `expected balance <= IM (not over-liquidated past the buffer), got balance=${m.balance}n im=${m.imRequired}n`, + ); + return m; +} + +/** + * Every block a `Futures.LotLiquidated` event was emitted at for `participant`. + * Unlike the `earliestEventBlock` readers this keeps the full list so tests can + * assert a batched liquidation collapses all lots into a single block (the + * anti-churn regression guard) — reusing the `Set` pattern from the + * delivery-coordinator multicall test. + */ +export async function readFuturesLotLiquidatedBlocks( + stack: DeployedStack, + user: Address, +): Promise { + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses.futures, + abi: stack.abis.futures, + eventName: "LotLiquidated", + args: { participant: user }, + fromBlock: 0n, + }); + const blocks: bigint[] = []; + for (const log of logs) { + if (log.blockNumber !== null) blocks.push(log.blockNumber); + } + return blocks; +} + +/** + * Asserts every supplied block number is identical — i.e. the events all rode + * a single transaction/block. `label` names the batched call for diagnostics. + * Mirrors the multicall batching invariant asserted in the delivery test. + */ +export function assertSingleBlock(blocks: readonly bigint[], label: string): void { + assert.ok(blocks.length > 0, `${label}: expected at least one event block`); + const unique = new Set(blocks.map((b) => b.toString())); + assert.equal( + unique.size, + 1, + `${label}: expected all events in a single block (batched), got ${unique.size} distinct blocks: ${[...unique].join(", ")}`, + ); +} + /** Resolves to true once `user` has no open orders on either venue. */ export async function expectNoOpenOrders( stack: DeployedStack, diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 69f8a19..06029e1 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -21,9 +21,14 @@ import { futuresLongCrashFixtureBuilder, futuresOrdersAndPositionFixtureBuilder, multiFuturesFixtureBuilder, + futuresPartialCrashFixtureBuilder, + futuresMultiExpiryPartialCrashFixtureBuilder, + perpsPartialCrashFixtureBuilder, crossVenuePerpsDominantFixtureBuilder, crossVenueFuturesDominantFixtureBuilder, crossVenueOrdersAndPositionsFixtureBuilder, + crossVenuePartialCrashFixtureBuilder, + crossVenueBothLegsCrashFixtureBuilder, } from "./scenarios.ts"; import { discoverUser, @@ -38,10 +43,15 @@ import { readFuturesOrderLiquidationBlock, readLotClosedBlock, readPerpsPosition, + readAccountMargins, expectPerpsClosed, expectFuturesClosed, expectNoOpenOrders, expectHealthy, + expectReducedToImBuffer, + readFuturesLotLiquidatedBlocks, + readFuturesLotExpiries, + assertSingleBlock, isCriticalAlert, waitFor, } from "./helpers.ts"; @@ -83,9 +93,16 @@ let twoUnderwaterUsersFixture: ReturnType; let futuresOrdersAndPositionFixture: ReturnType; let multiFuturesFixture: ReturnType; +let futuresPartialCrashFixture: ReturnType; +let futuresMultiExpiryPartialCrashFixture: ReturnType< + typeof futuresMultiExpiryPartialCrashFixtureBuilder +>; +let perpsPartialCrashFixture: ReturnType; let crossVenuePerpsDominantFixture: ReturnType; let crossVenueFuturesDominantFixture: ReturnType; let crossVenueOrdersAndPositionsFixture: ReturnType; +let crossVenuePartialCrashFixture: ReturnType; +let crossVenueBothLegsCrashFixture: ReturnType; before( async () => { @@ -105,9 +122,15 @@ before( futuresLongCrashFixture = futuresLongCrashFixtureBuilder(node.rpcUrl); futuresOrdersAndPositionFixture = futuresOrdersAndPositionFixtureBuilder(node.rpcUrl); multiFuturesFixture = multiFuturesFixtureBuilder(node.rpcUrl); + futuresPartialCrashFixture = futuresPartialCrashFixtureBuilder(node.rpcUrl); + futuresMultiExpiryPartialCrashFixture = + futuresMultiExpiryPartialCrashFixtureBuilder(node.rpcUrl); + perpsPartialCrashFixture = perpsPartialCrashFixtureBuilder(node.rpcUrl); crossVenuePerpsDominantFixture = crossVenuePerpsDominantFixtureBuilder(node.rpcUrl); crossVenueFuturesDominantFixture = crossVenueFuturesDominantFixtureBuilder(node.rpcUrl); crossVenueOrdersAndPositionsFixture = crossVenueOrdersAndPositionsFixtureBuilder(node.rpcUrl); + crossVenuePartialCrashFixture = crossVenuePartialCrashFixtureBuilder(node.rpcUrl); + crossVenueBothLegsCrashFixture = crossVenueBothLegsCrashFixtureBuilder(node.rpcUrl); }, { timeout: 60_000 }, ); @@ -349,6 +372,318 @@ describe("Futures liquidation", () => { ); }); +// ───────────────────────────────────────────────────────────────────────── +// Close-to-IM-buffer (partial liquidation — the anti-churn acceptance spec) +// ───────────────────────────────────────────────────────────────────────── + +describe("Liquidate down to the IM buffer", () => { + it( + "futures: one batched sweep closes a strict subset of lots into the [MM, IM] band", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds 12 long futures lots; a moderate crash + // (4.21 → 3.90) breaks MM but a subset close restores the IM buffer. + // Contract under test (the screenshot bug fix): the planner must NOT + // fan out into one-lot-per-tx churn. Instead a single + // `liquidatePositions(user, ids[])` closes the worst-first subset in + // ONE block, leaves ≥1 lot open, and lands `MM <= balance <= IM`. + const ctx = await loadFixture(futuresPartialCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + const lotsBefore = await readFuturesPositionIds(ctx, alice); + assert.equal( + lotsBefore.length, + ctx.aliceFuturesQty, + "precondition: alice should hold one lot per matched contract", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + // Landed in the buffer band — healthy but not over-liquidated. + await expectReducedToImBuffer(ctx, alice); + + // A strict subset closed: at least one lot remains open. + const lotsAfter = await readFuturesPositionIds(ctx, alice); + assert.ok( + lotsAfter.length > 0, + `expected a strict subset closed (>=1 lot open), got ${lotsAfter.length} remaining`, + ); + assert.ok( + lotsAfter.length < lotsBefore.length, + `expected some lots closed, before=${lotsBefore.length} after=${lotsAfter.length}`, + ); + + // Anti-churn regression guard: every closed lot rides a SINGLE block. + const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); + assert.equal( + liqBlocks.length, + lotsBefore.length - lotsAfter.length, + "expected one LotLiquidated event per closed lot", + ); + assertSingleBlock(liqBlocks, "futures liquidatePositions batch"); + }, + ); + + it( + "futures: one batched sweep balances the subset close across two expirations", + { timeout: 60_000 }, + async () => { + // Precondition: alice holds 6 long futures lots on EACH of two delivery + // dates (12 total). The moderate crash (4.21 → 3.90) breaks MM; because + // the risk model weights every lot by the same global + // `deliveryDurationDays`, the aggregate margin equals the single-expiry + // 12-lot case, so a worst-first subset restores the IM buffer. + // + // Contract under test (the balancing feature): the ONE + // `liquidatePositions(user, ids[])` call must draw its closed lots from + // BOTH books — not empty the first expiry before touching the second. + // We snapshot each lot's `deliveryAt` *before* the close (positions are + // deleted on liquidation), diff the surviving ids to find what closed, + // and assert the per-expiry counts are balanced (differ by ≤ 1) with at + // least one lot closed on each date. + const ctx = await loadFixture(futuresMultiExpiryPartialCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + const lotsBefore = await readFuturesPositionIds(ctx, alice); + assert.equal( + lotsBefore.length, + ctx.perExpiryQty * ctx.deliveryDates.length, + "precondition: alice holds perExpiryQty lots per delivery date", + ); + + // Snapshot id → expiry while every lot is still alive on-chain. + const expiryById = await readFuturesLotExpiries(ctx, lotsBefore); + const [firstDelivery, secondDelivery] = ctx.deliveryDates; + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + // Landed in the buffer band — healthy but not over-liquidated. + await expectReducedToImBuffer(ctx, alice); + + const lotsAfter = await readFuturesPositionIds(ctx, alice); + const survivors = new Set(lotsAfter.map((id) => id.toLowerCase())); + const closed = lotsBefore.filter((id) => !survivors.has(id.toLowerCase())); + + assert.ok( + lotsAfter.length > 0 && lotsAfter.length < lotsBefore.length, + `expected a strict subset closed, before=${lotsBefore.length} after=${lotsAfter.length}`, + ); + + // Attribute every closed lot back to its book. + let closedFirst = 0; + let closedSecond = 0; + for (const id of closed) { + const expiry = expiryById.get(id); + assert.ok(expiry !== undefined, `missing pre-close expiry for lot ${id}`); + if (expiry === firstDelivery) closedFirst += 1; + else if (expiry === secondDelivery) closedSecond += 1; + else assert.fail(`lot ${id} has an unexpected expiry ${expiry}`); + } + + // The balancing invariant: both books contributed, and the split is even + // (the round-robin worst-first selection differs by at most one lot). + assert.ok( + closedFirst >= 1 && closedSecond >= 1, + `expected the close to span BOTH expirations, got first=${closedFirst} second=${closedSecond}`, + ); + const skew = closedFirst > closedSecond ? closedFirst - closedSecond : closedSecond - closedFirst; + assert.ok( + skew <= 1, + `expected a balanced split across expirations (skew <= 1), got first=${closedFirst} second=${closedSecond}`, + ); + + // Anti-churn guard: the whole balanced subset rides a single block. + const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); + assert.equal( + liqBlocks.length, + closed.length, + "expected one LotLiquidated event per closed lot", + ); + assertSingleBlock(liqBlocks, "futures multi-expiry liquidatePositions batch"); + }, + ); + + it( + "perps: one sweep partially closes the net position into the [MM, IM] band", + { timeout: 60_000 }, + async () => { + // Precondition: alice is long 40 perps; a moderate crash (4.21 → 3.00) + // breaks MM but a partial-qty close restores the IM buffer. The perps + // venue must call `liquidatePosition(user, closeQty)` with an + // off-chain-sized `closeQty` so the residual long stays open and the + // account lands `MM <= balance <= IM` (not fully closed, not over-closed). + const ctx = await loadFixture(perpsPartialCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + const posBefore = await readPerpsPosition(ctx, alice); + assert.equal(posBefore.netQuantity, ctx.aliceQty, "precondition: alice long 40"); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectReducedToImBuffer(ctx, alice); + + const posAfter = await readPerpsPosition(ctx, alice); + assert.notEqual(posAfter.netQuantity, 0n, "expected a partial close, not a full close"); + assert.ok( + posAfter.netQuantity > 0n && posAfter.netQuantity < posBefore.netQuantity, + `expected reduced long, before=${posBefore.netQuantity} after=${posAfter.netQuantity}`, + ); + }, + ); + + it( + "cross-venue: one sweep reduces the dominant perps leg into the [MM, IM] band, leaving the futures leg open", + { timeout: 60_000 }, + async () => { + // Precondition: alice is long 40 perps AND long 1 futures lot; a moderate + // crash (4.21 → 3.00) puts the *combined* portfolio below MM. The perps + // leg dominates by unrealized loss ($48.40 vs $8.47), so the planner + // reduces it first. + // + // Contract under test: the perps `reduceToTarget` sizes its partial + // `closeQty` against WHOLE-portfolio margin — the still-open futures leg's + // loss and stress are folded into the [MM, IM] band it targets. A + // perps-only partial close therefore suffices; the account lands in the + // band and the futures leg is left fully intact (never touched). This is + // the cross-venue partial-liquidation path, distinct from the deep-crash + // cross-venue tests that wipe both books. + const ctx = await loadFixture(crossVenuePartialCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + const perpsBefore = await readPerpsPosition(ctx, alice); + assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 40 perps"); + const futuresBefore = await readFuturesPositionIds(ctx, alice); + assert.equal( + futuresBefore.length, + ctx.aliceFuturesQty, + "precondition: alice holds the futures lot(s)", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + // Landed in the buffer band across the combined portfolio. + await expectReducedToImBuffer(ctx, alice); + + // The dominant perps leg was partially closed — residual long still open. + const perpsAfter = await readPerpsPosition(ctx, alice); + assert.ok( + perpsAfter.netQuantity > 0n && perpsAfter.netQuantity < perpsBefore.netQuantity, + `expected a partial perps close, before=${perpsBefore.netQuantity} after=${perpsAfter.netQuantity}`, + ); + + // The futures leg was folded into the perps sizing math but never closed — + // reducing the dominant venue alone restored the whole-portfolio buffer. + const futuresAfter = await readFuturesPositionIds(ctx, alice); + assert.equal( + futuresAfter.length, + futuresBefore.length, + `expected the futures leg untouched, before=${futuresBefore.length} after=${futuresAfter.length}`, + ); + }, + ); + + it( + "cross-venue: a substantially underwater account is swept on BOTH venues into the [MM, IM] band", + { timeout: 60_000 }, + async () => { + // Precondition: alice is long 6 futures lots AND long 25 perps; a moderate + // crash (4.21 → 3.00) leaves the combined portfolio SUBSTANTIALLY under MM + // (~$8.12 deficit). The futures leg dominates by loss, so it's reduced + // first — but fully closing all 6 lots only frees ~$6.30 of MM stress, + // short of the deficit, so the account is still under MM. + // + // Contract under test: the planner's position loop must then take a + // SECOND iteration and reduce the perps leg (partial, continuous qty) to + // finish the job. End state: liquidation activity on BOTH venues in the + // one sweep, the account lands in the [MM, IM] band, and it is NOT fully + // wiped (a residual perps long stays open — this is the partial regime, + // not the bad-debt full-deleverage path). + const ctx = await loadFixture(crossVenueBothLegsCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + const perpsBefore = await readPerpsPosition(ctx, alice); + assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 25 perps"); + const futuresBefore = await readFuturesPositionIds(ctx, alice); + assert.equal( + futuresBefore.length, + ctx.aliceFuturesQty, + "precondition: alice holds 6 futures lots", + ); + + await ctx.makeLiquidatable(); + + // Substantially underwater: even the whole perps leg's stress relief can't + // close the gap on its own (a single-venue sweep would be insufficient). + const pre = await readAccountMargins(ctx, alice); + assert.ok( + pre.balance < pre.mmRequired, + `precondition: expected underwater, balance=${pre.balance}n mm=${pre.mmRequired}n`, + ); + + await runOneSweep(keeper, alice); + + // Landed in the buffer band across the combined portfolio. + await expectReducedToImBuffer(ctx, alice); + + // BOTH venues were liquidated in the sweep. + const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); + const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); + assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event (perps leg swept)"); + assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event (futures leg swept)"); + + // Both legs reduced; the account is not fully wiped (partial regime). + const perpsAfter = await readPerpsPosition(ctx, alice); + const futuresAfter = await readFuturesPositionIds(ctx, alice); + assert.ok( + perpsAfter.netQuantity < perpsBefore.netQuantity, + `expected the perps leg reduced, before=${perpsBefore.netQuantity} after=${perpsAfter.netQuantity}`, + ); + assert.ok( + futuresAfter.length < futuresBefore.length, + `expected the futures leg reduced, before=${futuresBefore.length} after=${futuresAfter.length}`, + ); + assert.ok( + perpsAfter.netQuantity > 0n || futuresAfter.length > 0, + "expected a strict subset closed (some position remains — landed in band, not bad debt)", + ); + }, + ); + + it( + "deep futures crash still fully closes (bad-debt path — guard skipped)", + { timeout: 60_000 }, + async () => { + // Regression: the close-to-IM change must NOT strand deep-crash + // accounts. A 99.8% crash leaves no in-band subset, so the batch + // closes every lot (the end-of-batch OverLiquidation guard is skipped + // once no positions remain). This keeps the existing bad-debt path green. + const ctx = await loadFixture(futuresLongCrashFixture, testClient); + keeper = buildKeeper(ctx); + await keeper.start(); + + const alice = ctx.accounts.alice.account.address; + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectFuturesClosed(ctx, alice); + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────── // Cross-venue coordination // ───────────────────────────────────────────────────────────────────────── diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 0dd64ee..3282a64 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -102,6 +102,50 @@ export interface MultiFuturesFixture extends BaseFixture { makeLiquidatable(): Promise; } +/** + * Alice holds many futures lots and takes a *moderate* crash — deep enough + * to break MM but shallow enough that closing a strict subset of lots + * restores the IM buffer. Contrast with `futuresLongCrashFixtureBuilder` + * (a 99.8% crash that fully liquidates into bad debt). This is the anti-churn + * scenario: one batched `liquidatePositions` call should land the account in + * the `[MM, IM]` band with lots still open. + */ +export interface FuturesPartialCrashFixture extends BaseFixture { + aliceDeposit: bigint; + aliceFuturesQty: number; + makeLiquidatable(): Promise; +} + +/** + * Alice holds one perps net position and takes a *moderate* crash — below MM + * but recoverable by a partial-qty close back into the `[MM, IM]` band. The + * mirror of `FuturesPartialCrashFixture` for the perps `liquidatePosition(user, + * closeQty)` partial path. + */ +export interface PerpsPartialCrashFixture extends BaseFixture { + aliceDeposit: bigint; + aliceQty: bigint; + makeLiquidatable(): Promise; +} + +/** + * Alice holds equal-size futures long books on TWO delivery dates (separate + * markets) and takes the same *moderate* crash as `FuturesPartialCrashFixture`. + * A subset close restores the IM buffer — and because every lot carries the + * same global `deliveryDurationDays` risk weight, the aggregate margin matches + * the single-expiry 12-lot case. Used to prove the keeper's ONE + * `liquidatePositions` tx spreads the close *across both expirations* instead + * of draining one book first. + */ +export interface MultiExpiryFuturesPartialCrashFixture extends BaseFixture { + aliceDeposit: bigint; + /** The two delivery dates Alice holds lots on. */ + deliveryDates: readonly [bigint, bigint]; + /** Lots per delivery date (equal split). */ + perExpiryQty: number; + makeLiquidatable(): Promise; +} + /** Alice holds a futures long AND has a resting (unmatched) buy order. */ export interface FuturesOrdersAndPositionFixture extends FuturesLongFixture { /** Count of resting orders held by alice at fixture time. */ @@ -456,6 +500,278 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { }; } +/** + * Alice holds 12 long futures lots at the first delivery date; a moderate + * hashprice crash (4.21 → 3.90 / 100 TH/s / day) drives her below MM while + * leaving enough headroom that closing a worst-first subset of lots restores + * `balance >= IM`. Sizing (deliveryDurationDays = 7, PME shocks 10% IM / 5% + * MM, $1 flat liquidation fee): + * - unrealized loss / lot after crash ≈ (4.21 − 3.90) · 7 = $2.17 + * - MM stress / lot ≈ 0.05 · 3.90 · 7 = $1.365, IM stress ≈ $2.73 + * - MM_req₀ ≈ 12 · (1.365 + 2.17) = $42.42 > $40 deposit ⇒ underwater + * - closing ~7–10 lots frees enough MM stress to re-cross MM while staying + * at/under IM (the rest stay open) — a genuine partial liquidation. + * Deposit $40 also clears the entry IM (12 · 0.10 · 4.21 · 7 ... perp-free + * futures IM ≈ $35.36) so Alice can open the position pre-crash. + */ +export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const aliceFuturesQty = 12; + + // Zero the futures taker fee for this fixture only. Opening 12 lots costs + // an entry IM of 84·(0.1·$4.21) = $35.36, which fits the $40 deposit — but + // the default $1/lot taker fee ($12) would drop the post-match balance to + // $28 < IM and revert `InsufficientMarginBalance`. Zeroing it keeps the + // [MM, IM] band math clean; the $1/lot *liquidation* fee still applies to + // the sweep (so the solver's fee-aware sizing is still exercised). + await setFuturesTakerFee(base, 0n); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + // Moderate crash: 4.21 → 3.90. Deep enough to break MM, shallow enough + // that a subset of lots restores the IM buffer. + makeLiquidatable: () => + base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + }; + }; +} + +/** + * Alice holds 6 long futures lots on EACH of two delivery dates (12 total), + * then takes the same moderate crash (4.21 → 3.90) as + * `futuresPartialCrashFixtureBuilder`. Because the on-chain futures risk model + * weights every lot by the single global `deliveryDurationDays` (7) regardless + * of which date it delivers on, the aggregate MM/IM and unrealized loss are + * identical to the single-expiry 12-lot fixture — so the same $40 deposit + * breaks MM and a worst-first subset restores the IM buffer. The distinction + * under test: the keeper's ONE `liquidatePositions` sweep must close lots from + * BOTH expirations (balanced), not empty the first book before touching the + * second. + */ +export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const perExpiryQty = 6; + const firstDeliveryAt = base.config.futuresFirstDeliveryDate; + const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_DELIVERY_INTERVAL_DAYS + + // Zero the taker fee (see `futuresPartialCrashFixtureBuilder`) so the 12-lot + // entry IM (~$35.36) fits the $40 deposit; the liquidation-fee payout is + // already disabled contract-side. + await setFuturesTakerFee(base, 0n); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + for (const deliveryAt of [firstDeliveryAt, secondDeliveryAt]) { + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt, + quantity: perExpiryQty, + }); + } + + return { + ...base, + aliceDeposit, + deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, + perExpiryQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + }; + }; +} + +/** + * Alice holds a single 40-qty perps long; a moderate crash (4.21 → 3.00) + * puts her below MM but a *partial* qty close restores `balance >= IM`. + * Sizing (PME 10% IM / 5% MM, $1 perps liquidation fee): + * - loss / qty after crash = (4.21 − 3.00) = $1.21 + * - MM stress / qty = 0.05 · 3.00 = $0.15, IM stress = $0.30 + * - MM_req₀ ≈ 40 · (0.15 + 1.21) = $54.4 > $52 deposit ⇒ underwater + * - closing ~23–31 qty re-crosses MM while staying at/under IM (residual + * long stays open) — the partial-close path under test. + */ +export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("52", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const aliceQty = parseUnits("40", base.config.quantityDecimals); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: aliceQty, + }); + + return { + ...base, + aliceDeposit, + aliceQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + }; + }; +} + +/** + * Cross-venue *partial* crash — the reduce-to-IM-buffer path spanning both + * venues. Alice holds a dominant 40-qty perps long plus a small 1-lot futures + * long. A moderate crash (4.21 → 3.00) puts the *combined* portfolio below MM, + * but the account is recoverable by a partial close. Because both legs are long + * at the same entry, the portfolio behaves like one net-long book of size + * `perpQty + deliveryDays·futuresLots` (= 40 + 7 = 47 delta units) for margin + * purposes. + * + * Sizing (PME 10% IM / 5% MM, deliveryDays = 7, fee payout disabled): + * - mmReq(3.00) = 47 · (4.21 − 3.00·0.95) = 47 · 1.36 = $63.92 + * - imReq(3.00) = 47 · (4.21 − 3.00·0.90) = 47 · 1.51 = $70.97 + * - $61 deposit < $63.92 ⇒ underwater by ~$2.92 + * - each closed delta unit lifts mmSurplus by mmShock·P = $0.15, imSurplus by + * $0.30, so the deepest in-band close is δ ≈ (70.97−61)/0.30 ≈ 33.2 delta + * units — a PARTIAL perps close (≈6.8 units of the 40 stay open), suppliable + * by the perps leg alone so the futures leg is never touched. + * + * The flat $1/lot futures taker fee is zeroed for this fixture (as in + * `futuresPartialCrashFixtureBuilder`) so it doesn't eat into the narrow + * partial-close band and flip the dominant-leg close from partial to full. + * + * Contract under test: the planner reduces the *dominant* venue (perps, by + * unrealized loss) down to the portfolio `[MM, IM]` band in one sweep. Because + * the perps solver sizes against whole-portfolio margin (the futures leg's loss + * AND stress are folded in), a perps-only partial close suffices — the futures + * leg is left fully intact. This is the cross-venue analogue of the single-venue + * partial tests, and distinct from the deep-crash cross-venue tests that fully + * wipe both books. + */ +export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("61", base.config.tokenDecimals); + const bobDeposit = parseUnits("5000", base.config.tokenDecimals); + const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); + const aliceFuturesQty = 1; + + await setFuturesTakerFee(base, 0n); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + }; + }; +} + +/** + * Cross-venue *deep-but-recoverable* crash — the account is substantially + * underwater, so reducing the single worst venue to EMPTY still leaves it below + * MM and the planner must sweep the SECOND venue too before landing in the + * `[MM, IM]` band. This exercises the planner's multi-iteration cross-venue loop + * in the partial regime (distinct from both the single-venue-suffices partial + * test and the 99.8% deep-crash test that wipes everything into bad debt). + * + * Alice holds a dominant 6-lot futures long + a 25-qty perps long (delta units: + * futures 6·7 = 42, perps 25; S = 67). Moderate crash 4.21 → 3.00: + * - mmReq(3.00) = 67 · 1.36 = $91.12 ; imReq = 67 · 1.51 = $101.17 + * - $83 deposit ⇒ underwater by ~$8.12 (substantial) + * - futures is worst by loss ($50.82 > $30.25), so it's reduced first — but + * even fully closing all 6 futures lots only lifts mmSurplus by 6·0.15·7 = + * $6.30, short of the $8.12 deficit, so the account is STILL under MM (the + * futures leg simply doesn't have the lots to close the gap alone). + * - the planner then takes a SECOND iteration and reduces the perps leg. Perps + * closes by a *continuous* quantity, so the solver lands the account + * precisely on the IM boundary — a robust in-band result (residual perps + * ~6.4 qty stays open), unlike the discrete futures-lot granularity. + * + * Net effect the test asserts: BOTH venues carry liquidation activity in the + * one sweep (futures fully closed, perps partially closed), the account lands in + * `[MM, IM]`, and it is not fully wiped (the perps leg keeps a residual long). + * The futures taker fee is zeroed (as elsewhere) so the $1/lot open cost doesn't + * shift the sizing. + */ +export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + const aliceDeposit = parseUnits("83", base.config.tokenDecimals); + const bobDeposit = parseUnits("5000", base.config.tokenDecimals); + const alicePerpsQty = parseUnits("25", base.config.quantityDecimals); + const aliceFuturesQty = 6; + + await setFuturesTakerFee(base, 0n); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchPerpsTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialHashprice, + deliveryAt: base.config.futuresFirstDeliveryDate, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + }; + }; +} + /** * Alice holds simultaneous perps + futures longs. A single oracle move * puts both legs underwater at once, exercising the planner's coordinated @@ -688,6 +1004,19 @@ async function placeFuturesOrder( await base.publicClient.waitForTransactionReceipt({ hash }); } +/** Owner-only: set the futures per-lot taker fee (token decimals). */ +async function setFuturesTakerFee(stack: DeployedStack, fee: bigint): Promise { + const hash = await stack.accounts.owner.client.writeContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "setTakerFee", + args: [fee], + chain: hardhat, + account: stack.accounts.owner.account, + }); + await stack.publicClient.waitForTransactionReceipt({ hash }); +} + async function writeOracle(stack: DeployedStack, oracle: Address, price: bigint): Promise { const hash = await stack.accounts.owner.client.writeContract({ address: oracle, diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts index 7ff5eb1..e1e56ef 100644 --- a/keeper/tests/predict/mm.test.ts +++ b/keeper/tests/predict/mm.test.ts @@ -62,7 +62,7 @@ describe("predict/mm: netDeltaWad", () => { // Buyer of 1 contract over 30 days → +30 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, deliveryDays: 30n, }, @@ -73,7 +73,7 @@ describe("predict/mm: netDeltaWad", () => { it("subtracts futures seller delta", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n }], + positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, deliveryDays: 30n, }, @@ -86,8 +86,8 @@ describe("predict/mm: netDeltaWad", () => { perp: { netQty: 1_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, // +1e18 futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n }, - { id: "0xbb", isBuyer: false, entryPricePerDay: 60n }, + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, + { id: "0xbb", isBuyer: false, entryPricePerDay: 60n, deliveryAt: 1_756_416_000n }, ], orderMargin: 0n, deliveryDays: 30n, @@ -171,7 +171,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("buyer loses when P drops below entry; loss scales by deliveryDays", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, deliveryDays: 30n, }, @@ -183,7 +183,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("seller loses when P rises above entry", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n }], + positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, deliveryDays: 30n, }, @@ -195,8 +195,8 @@ describe("predict/mm: futuresUnrealizedLoss", () => { const snap = emptySnapshot({ futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n }, // P=40 → loses 300 - { id: "0xbb", isBuyer: false, entryPricePerDay: 30n }, // P=40 → loses 300 + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 → loses 300 + { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 → loses 300 ], orderMargin: 0n, deliveryDays: 30n, diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index 695e055..dc32b26 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -30,7 +30,10 @@ function makeConfig(): Config { */ function makeChain(scripted: { futuresPositionIds?: readonly string[]; - futuresPositions?: Record; + futuresPositions?: Record< + string, + { buyer: string; seller: string; buyPricePerDay: bigint; sellPricePerDay: bigint; deliveryAt: bigint } + >; perpNetQty?: bigint; perpEntry?: bigint; perpOrderMargin?: bigint; @@ -135,12 +138,14 @@ describe("predict/snapshot: readAccountSnapshot", () => { seller: "0x000000000000000000000000000000000000feed", buyPricePerDay: 50n, sellPricePerDay: 51n, + deliveryAt: 1_756_416_000n, }, [SELLER_POS_ID]: { buyer: "0x000000000000000000000000000000000000feed", seller: USER, buyPricePerDay: 60n, sellPricePerDay: 59n, + deliveryAt: 1_759_008_000n, }, }, }); @@ -150,7 +155,9 @@ describe("predict/snapshot: readAccountSnapshot", () => { const seller = snap.futures.positions.find((p) => p.id === SELLER_POS_ID); assert.equal(buyer?.isBuyer, true); assert.equal(buyer?.entryPricePerDay, 50n); + assert.equal(buyer?.deliveryAt, 1_756_416_000n); assert.equal(seller?.isBuyer, false); assert.equal(seller?.entryPricePerDay, 59n); + assert.equal(seller?.deliveryAt, 1_759_008_000n); }); }); diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts index 63c2189..099f788 100644 --- a/keeper/tests/predict/solve.test.ts +++ b/keeper/tests/predict/solve.test.ts @@ -145,7 +145,7 @@ describe("predict/solve: solveLiquidationThresholds", () => { const snap = emptySnapshot({ balance: 200n, futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n }], + positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, deliveryDays: 30n, }, diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts new file mode 100644 index 0000000..b239995 --- /dev/null +++ b/keeper/tests/predict/solveTarget.test.ts @@ -0,0 +1,228 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address, Hex } from "viem"; +import { + simulateFuturesClose, + simulatePerpClose, + solveFuturesLotsToTarget, + solvePerpCloseToTarget, +} from "../../src/predict/solve.ts"; +import { imSurplus, mmSurplus } from "../../src/predict/mm.ts"; +import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; + +const USER = "0x1111111111111111111111111111111111111111" as Address; +const QTY_SCALE = 10n ** 6n; + +// PME defaults used across the stack: 10% IM / 5% MM, USDC 6-dec, perps qty 6-dec. +const PARAMS: MMParams = { + imSpotShock: 10n ** 17n, + mmSpotShock: 5n * 10n ** 16n, + tokenDecimals: 6, + perpQuantityDecimals: 6, +}; + +const FEE = 1_000_000n; // $1 flat liquidation fee + +// Default single-expiry timestamp for lots whose test doesn't care about the +// expiration grouping (keeps their behaviour identical to pre-balancing). +const EXPIRY_A = 1_756_416_000n; +const EXPIRY_B = 1_759_008_000n; + +function futuresLot(id: Hex, entryPricePerDay: bigint, isBuyer = true, deliveryAt = EXPIRY_A) { + return { id, isBuyer, entryPricePerDay, deliveryAt }; +} + +function futuresSnapshot(overrides: Partial = {}): AccountSnapshot { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, + futures: { positions: [], orderMargin: 0n, deliveryDays: 7n }, + ...overrides, + }; +} + +/** 12 identical $4.21/day long lots — the integration `futuresPartialCrash` shape. */ +function twelveLongLots(): AccountSnapshot { + const positions = []; + for (let i = 0; i < 12; i++) { + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n)); + } + return futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); +} + +describe("predict/solve: solveFuturesLotsToTarget", () => { + it("returns an empty set when the account is already healthy", () => { + const snap = futuresSnapshot({ + balance: 1_000_000_000n, + futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, 4_210_000n)], orderMargin: 0n, deliveryDays: 7n }, + }); + const ids = solveFuturesLotsToTarget(snap, PARAMS, 4_000_000n, FEE); + assert.equal(ids.length, 0); + }); + + it("closes a strict worst-first subset that lands inside the [MM, IM] band", () => { + const snap = twelveLongLots(); + const P = 3_900_000n; // moderate crash → underwater but recoverable + + // Precondition: the account really is underwater at P. + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); + + const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); + assert.ok(ids.length > 0, "should close at least one lot"); + assert.ok(ids.length < snap.futures.positions.length, "should leave >=1 lot open (strict subset)"); + + // The chosen subset lands the account in the [MM, IM] buffer band. + const after = simulateFuturesClose(snap, ids, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM (no over-liquidation)"); + }); + + it("is the DEEPEST in-band subset — closing one more worst-first lot breaches IM", () => { + const snap = twelveLongLots(); + const P = 3_900_000n; + const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); + + // There is still a lot to add and doing so would push balance over IM. + if (ids.length < snap.futures.positions.length - 1) { + const remaining = snap.futures.positions.find((p) => !ids.includes(p.id)); + assert.ok(remaining, "expected a remaining lot to test the deepest boundary"); + const oneMore = simulateFuturesClose(snap, [...ids, remaining.id], P, FEE); + assert.ok( + imSurplus(oneMore, PARAMS, P) > 0n, + "closing one more lot should overshoot IM (proves the subset is the deepest)", + ); + } + }); + + it("returns the full set (all ids) on a deep crash with no in-band subset", () => { + const snap = twelveLongLots(); + const P = 100_000n; // ~98% crash → bad debt even after closing everything + const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); + assert.equal(ids.length, snap.futures.positions.length, "deep crash fully closes"); + }); + + it("degenerate IM == MM: targets minimal healthy (no upper IM bound)", () => { + const snap = twelveLongLots(); + const P = 3_900_000n; + const degenerate: MMParams = { ...PARAMS, imSpotShock: PARAMS.mmSpotShock }; + const ids = solveFuturesLotsToTarget(snap, degenerate, P, FEE); + assert.ok(ids.length > 0 && ids.length <= snap.futures.positions.length); + const after = simulateFuturesClose(snap, ids, P, FEE); + assert.ok(mmSurplus(after, degenerate, P) >= 0n, "healthy at MM"); + }); + + it("balances the close across futures expirations (does not drain one expiry's book)", () => { + // 12 identical $4.21/day long lots split evenly across two expiration + // dates (markets). With equal per-lot loss, an expiry-blind worst-first + // solver would just take a prefix in input order — draining EXPIRY_A + // entirely before touching EXPIRY_B. The balanced solver must instead + // spread the closures across both books. + const positions: AccountSnapshot["futures"]["positions"] = []; + for (let i = 0; i < 6; i++) { + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + } + for (let i = 6; i < 12; i++) { + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + } + const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); + const P = 3_900_000n; + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); + + const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); + assert.ok(ids.length > 1, "should close more than one lot so balancing is observable"); + + const byExpiry = (deliveryAt: bigint) => + ids.filter((id) => positions.find((p) => p.id === id)?.deliveryAt === deliveryAt).length; + const countA = byExpiry(EXPIRY_A); + const countB = byExpiry(EXPIRY_B); + + assert.ok(countA >= 1 && countB >= 1, `both expirations must be reduced (A=${countA}, B=${countB})`); + assert.ok( + countA - countB <= 1 && countB - countA <= 1, + `closures must be balanced across expirations within one lot (A=${countA}, B=${countB})`, + ); + + // Still lands in the [MM, IM] band — balancing must not sacrifice the target. + const after = simulateFuturesClose(snap, ids, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); + }); + + it("balances proportionally to each expiry's book size when expiries differ in size", () => { + // EXPIRY_A holds 8 lots, EXPIRY_B holds 4 lots (2:1). A balanced close + // should reduce them roughly in proportion — A closes about twice as many + // lots as B — rather than emptying the smaller book first. + const positions: AccountSnapshot["futures"]["positions"] = []; + for (let i = 0; i < 8; i++) { + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + } + for (let i = 8; i < 12; i++) { + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + } + const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); + const P = 3_900_000n; + + const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); + const byExpiry = (deliveryAt: bigint) => + ids.filter((id) => positions.find((p) => p.id === id)?.deliveryAt === deliveryAt).length; + const countA = byExpiry(EXPIRY_A); + const countB = byExpiry(EXPIRY_B); + + // The larger book (A, 2×) is reduced at least as much as the smaller (B), + // and the smaller book is not fully drained while the larger is untouched. + assert.ok(countA >= countB, `larger book should not close fewer (A=${countA}, B=${countB})`); + assert.ok(countB >= 1, "smaller book still participates"); + }); +}); + +function perpSnapshot(netQty: bigint, entryPrice: bigint, balance: bigint): AccountSnapshot { + return { + user: USER, + balance, + perp: { netQty, entryPrice, orderMargin: 0n, fundingOwed: 0n }, + futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + }; +} + +describe("predict/solve: solvePerpCloseToTarget", () => { + it("returns 0 when the account is already healthy", () => { + const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 1_000_000_000n); + assert.equal(solvePerpCloseToTarget(snap, PARAMS, 3_000_000n, FEE), 0n); + }); + + it("returns a partial closeQty that lands inside the [MM, IM] band", () => { + // Long 40 @ $4.21, deposit $52, crash to $3.00 — the integration + // `perpsPartialCrash` shape. + const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 52_000_000n); + const P = 3_000_000n; + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); + + const q = solvePerpCloseToTarget(snap, PARAMS, P, FEE); + const absNet = 40n * QTY_SCALE; + assert.ok(q > 0n, "should close a positive quantity"); + assert.ok(q < absNet, "should be a PARTIAL close (residual position remains)"); + + const after = simulatePerpClose(snap, q, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); + }); + + it("returns the full quantity on a deep crash (bad-debt full close)", () => { + const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 52_000_000n); + const q = solvePerpCloseToTarget(snap, PARAMS, 100_000n, FEE); + assert.equal(q, 40n * QTY_SCALE, "deep crash fully closes"); + }); + + it("handles a short position (pump) symmetrically", () => { + // Short 40 @ $4.21, deposit $52, pump to $5.47 (+30%). + const snap = perpSnapshot(-40n * QTY_SCALE, 4_210_000n, 52_000_000n); + const P = 5_470_000n; + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "short must start underwater on the pump"); + const q = solvePerpCloseToTarget(snap, PARAMS, P, FEE); + assert.ok(q > 0n && q < 40n * QTY_SCALE, "partial close of the short"); + const after = simulatePerpClose(snap, q, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n); + assert.ok(imSurplus(after, PARAMS, P) <= 0n); + }); +}); diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index a8d9e0d..ba0c9bf 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -27,7 +27,7 @@ const STUB_CONFIG: Config = { }, vault: { address: "0x0000000000000000000000000000000000000001" }, perps: { address: "0x0000000000000000000000000000000000000002" }, - futures: { address: "0x0000000000000000000000000000000000000003" }, + futures: { address: "0x0000000000000000000000000000000000000003", maxLotsPerLiquidationTx: 50 }, pme: { address: "0x0000000000000000000000000000000000000004" }, oracle: { hashpriceUsdcAddress: "0x0000000000000000000000000000000000000005", diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts new file mode 100644 index 0000000..b50c9d4 --- /dev/null +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -0,0 +1,244 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address, Hex } from "viem"; +import { FuturesVenue } from "../../src/venues/futures.ts"; +import { PerpsVenue } from "../../src/venues/perps.ts"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; + +const USER = "0x1111111111111111111111111111111111111111" as Address; +const VAULT = "0x000000000000000000000000000000000000000a" as Address; +const PME = "0x000000000000000000000000000000000000000b" as Address; +const PERPS = "0x000000000000000000000000000000000000c0de" as Address; +const FUTURES = "0x000000000000000000000000000000000000f00d" as Address; + +// PME defaults: 10% IM / 5% MM, USDC 6-dec, perps qty 6-dec (real IM buffer). +const IM_SHOCK = 10n ** 17n; +const MM_SHOCK = 5n * 10n ** 16n; + +interface ReadCall { + address: Address; + functionName: string; + args?: readonly unknown[]; +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as ConstructorParameters[2]; + +function makeConfigStub(dryRun: boolean, maxLotsPerLiquidationTx?: number): Config { + return { + vault: { address: VAULT }, + pme: { address: PME }, + perps: { address: PERPS }, + futures: { address: FUTURES, maxLotsPerLiquidationTx }, + coordinator: { confirmationBlocks: 1 }, + keeper: { dryRun }, + } as Config; +} + +/** + * Chain stub that serves the two snapshot round-trips (readMMParams + + * readAccountSnapshot), the market-price / liquidation-fee reads, and records + * the `simulateContract` call so tests can assert the batched calldata the + * venue sizes. `dryRun: true` means `sendLiquidate` never writes a tx. + */ +function makeChainStub(opts: { + balance: bigint; + marketPrice: bigint; + liquidationFee: bigint; + perp: { netQuantity: bigint; aggregatedEntryPrice: bigint }; + futuresPositionIds: readonly Hex[]; + futuresPosition?: { buyer: Address; buyPricePerDay: bigint; sellPricePerDay: bigint }; + onSimulate: (call: ReadCall) => void; +}): Chain { + return { + account: { address: "0x0000000000000000000000000000000000009999" as Address }, + publicClient: { + readContract: async (call: ReadCall) => { + if (call.functionName === "getMarketPrice") return opts.marketPrice; + if (call.functionName === "liquidationFee") return opts.liquidationFee; + throw new Error(`unexpected readContract: ${call.functionName}`); + }, + multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => { + const fns = contracts.map((c) => c.functionName); + // readMMParams + if (fns[0] === "imSpotShock") return [IM_SHOCK, MM_SHOCK, 6, 6]; + // readAccountSnapshot bulk read + if (fns[0] === "balanceOf") { + return [ + opts.balance, + { netQuantity: opts.perp.netQuantity, aggregatedEntryPrice: opts.perp.aggregatedEntryPrice }, + 0n, // getOrderMargin + 0n, // getPendingFunding + 0n, // getFuturesOrderMargin + opts.futuresPositionIds, + 7, // deliveryDurationDays + ]; + } + // readAccountSnapshot per-position hydration + if (fns[0] === "getPositionById") { + const p = opts.futuresPosition; + if (p === undefined) throw new Error("no futuresPosition scripted"); + return contracts.map(() => ({ + seller: "0x0000000000000000000000000000000000005e11" as Address, + buyer: p.buyer, + buyPricePerDay: p.buyPricePerDay, + sellPricePerDay: p.sellPricePerDay, + deliveryAt: 1_756_416_000n, + })); + } + throw new Error(`unexpected multicall head: ${fns[0]}`); + }, + simulateContract: async (call: ReadCall) => { + opts.onSimulate(call); + return { request: { ...call } }; + }, + }, + } as unknown as Chain; +} + +describe("futures venue: reduceToTarget", () => { + it("sizes a strict worst-first lot subset and submits one liquidatePositions batch", async () => { + // 12 long lots @ $4.21/day, $40 deposit, crash to $3.90 — underwater but + // recoverable (mirrors the solver's in-band fixture). + const ids: Hex[] = []; + for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); + let simulated: ReadCall | undefined; + const chain = makeChainStub({ + balance: 40_000_000n, + marketPrice: 3_900_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, + futuresPositionIds: ids, + futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + onSimulate: (call) => { + simulated = call; + }, + }); + const venue = new FuturesVenue(chain, makeConfigStub(true), silentLogger); + const outcome = await venue.reduceToTarget(USER); + + assert.ok(simulated, "should simulate a liquidatePositions call"); + assert.equal(simulated?.functionName, "liquidatePositions"); + const [participant, batch] = simulated?.args as [Address, Hex[]]; + assert.equal(participant, USER); + assert.ok(batch.length > 0 && batch.length < ids.length, "strict subset of lots"); + // dryRun → no fee, but the planner still learns how many lots closed. + assert.ok("feeEarned" in outcome && outcome.positionsClosed === batch.length); + }); + + it("caps the batch to maxLotsPerLiquidationTx (gas-bounded chunking)", async () => { + // 12 long lots @ $4.21/day, $40 deposit, crash to $1.00 — a deep crash the + // solver resolves to a FULL close (all 12 ids). With a cap below 12, + // `reduceToTarget` must send only the worst-first prefix and report + // `positionsClosed` == cap; the planner loop drains the rest next iteration. + const ids: Hex[] = []; + for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); + + // Uncapped target first, so the assertion is robust to the solver's sizing. + let full: Hex[] = []; + const chainFull = makeChainStub({ + balance: 40_000_000n, + marketPrice: 1_000_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, + futuresPositionIds: ids, + futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + onSimulate: (call) => { + full = (call.args as [Address, Hex[]])[1]; + }, + }); + await new FuturesVenue(chainFull, makeConfigStub(true), silentLogger).reduceToTarget(USER); + assert.ok(full.length >= 2, `scenario should want ≥2 lots so the cap bites (got ${full.length})`); + + const cap = full.length - 1; + let chunk: Hex[] = []; + const chainCap = makeChainStub({ + balance: 40_000_000n, + marketPrice: 1_000_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, + futuresPositionIds: ids, + futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + onSimulate: (call) => { + chunk = (call.args as [Address, Hex[]])[1]; + }, + }); + const outcome = await new FuturesVenue(chainCap, makeConfigStub(true, cap), silentLogger).reduceToTarget( + USER, + ); + + assert.equal(chunk.length, cap, "batch capped to maxLotsPerLiquidationTx"); + assert.deepEqual(chunk, full.slice(0, cap), "sends the worst-first prefix of the full target"); + assert.ok("feeEarned" in outcome && outcome.positionsClosed === cap); + }); + + it("skips with nothingToClose when the account is already at/above the IM buffer", async () => { + const ids: Hex[] = [("0x" + "01".repeat(32)) as Hex]; + let simulateCalled = false; + const chain = makeChainStub({ + balance: 1_000_000_000n, // fully collateralised + marketPrice: 3_900_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, + futuresPositionIds: ids, + futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + onSimulate: () => { + simulateCalled = true; + }, + }); + const venue = new FuturesVenue(chain, makeConfigStub(true), silentLogger); + const outcome = await venue.reduceToTarget(USER); + assert.deepEqual(outcome, { skipped: "nothingToClose" }); + assert.equal(simulateCalled, false, "no tx simulated when nothing to close"); + }); +}); + +describe("perps venue: reduceToTarget", () => { + it("sizes a partial closeQty and submits one liquidatePosition call", async () => { + // Long 40 @ $4.21, $52 deposit, crash to $3.00 — underwater, partial-recoverable. + let simulated: ReadCall | undefined; + const chain = makeChainStub({ + balance: 52_000_000n, + marketPrice: 3_000_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 40n * 10n ** 6n, aggregatedEntryPrice: 4_210_000n }, + futuresPositionIds: [], + onSimulate: (call) => { + simulated = call; + }, + }); + const venue = new PerpsVenue(chain, makeConfigStub(true), silentLogger); + const outcome = await venue.reduceToTarget(USER); + + assert.ok(simulated, "should simulate a liquidatePosition call"); + assert.equal(simulated?.functionName, "liquidatePosition"); + const [user, closeQty] = simulated?.args as [Address, bigint]; + assert.equal(user, USER); + assert.ok(closeQty > 0n && closeQty < 40n * 10n ** 6n, "partial close (residual remains)"); + assert.ok("feeEarned" in outcome && outcome.positionsClosed === 1); + }); + + it("skips with nothingToClose when the perps account is already healthy", async () => { + let simulateCalled = false; + const chain = makeChainStub({ + balance: 1_000_000_000n, + marketPrice: 3_000_000n, + liquidationFee: 1_000_000n, + perp: { netQuantity: 40n * 10n ** 6n, aggregatedEntryPrice: 4_210_000n }, + futuresPositionIds: [], + onSimulate: () => { + simulateCalled = true; + }, + }); + const venue = new PerpsVenue(chain, makeConfigStub(true), silentLogger); + const outcome = await venue.reduceToTarget(USER); + assert.deepEqual(outcome, { skipped: "nothingToClose" }); + assert.equal(simulateCalled, false); + }); +}); diff --git a/points-indexer/package.json b/points-indexer/package.json index 1c08a55..4fa4feb 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -7,7 +7,6 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", - "postinstall": "node scripts/patch-matchstick-ts.mjs", "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", "codegen": "graph codegen", @@ -35,9 +34,9 @@ "assemblyscript": "^0.19.23", "collateral-margin-contracts": "link:../contracts", "hardhat": "^3.4.0", - "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#main&path:packages/hardhat-matchstick-ts", + "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/hardhat-matchstick-ts", "matchstick-as": "0.6.0", - "matchstick-ts": "github:lsheva/matchstick-ts#main&path:packages/matchstick-ts", + "matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/matchstick-ts", "typescript": "^5.9.3", "viem": "^2.42.1" }, diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml index 46e8e14..3d24913 100644 --- a/points-indexer/pnpm-lock.yaml +++ b/points-indexer/pnpm-lock.yaml @@ -37,14 +37,14 @@ importers: specifier: ^3.4.0 version: 3.9.0 hardhat-matchstick-ts: - specifier: github:lsheva/matchstick-ts#main&path:packages/hardhat-matchstick-ts - version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0) + 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.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0) matchstick-as: specifier: 0.6.0 version: 0.6.0 matchstick-ts: - specifier: github:lsheva/matchstick-ts#main&path:packages/matchstick-ts - version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#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#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)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1255,9 +1255,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/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts: - resolution: {path: packages/hardhat-matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302} - version: 0.4.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 engines: {node: '>=22.6'} peerDependencies: '@nomicfoundation/hardhat-network-helpers': ^3 @@ -1609,9 +1609,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/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/matchstick-ts: - resolution: {path: packages/matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302} - version: 0.4.0 + 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 engines: {node: '>=22.6'} hasBin: true peerDependencies: @@ -3571,7 +3571,7 @@ snapshots: graphql@16.11.0: {} - hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.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.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0): dependencies: '@nomicfoundation/hardhat-network-helpers': 3.0.10(hardhat@3.9.0) '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) @@ -3943,7 +3943,7 @@ snapshots: dependencies: wabt: 1.0.24 - matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/b62ffee63eb770a039e88ca2a759dcfcd2fb8302#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/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)): 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/scripts/patch-matchstick-ts.mjs b/points-indexer/scripts/patch-matchstick-ts.mjs deleted file mode 100644 index cd15e99..0000000 --- a/points-indexer/scripts/patch-matchstick-ts.mjs +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node -/** - * Postinstall patch for matchstick-ts. - * - * Upstream (lsheva/matchstick-ts) doesn't yet handle Solidity tuple/struct - * event params end-to-end: - * - `event-capture.js#serializeParams` did `JSON.stringify(value)` on object - * fields, which throws when the struct contains a `bigint` (every uint256). - * - `assembly/index.ts#jsonValueToEthereumValue` only handled scalar JSON - * kinds, falling back to `Value.fromString(value.toString())` for arrays. - * The AS-generated event class then called `.toTuple()` on the wrapper - * and aborted with "Ethereum value is not a tuple". - * - * The points contracts only emit scalar event params today, but we keep the - * patch for parity with the futures indexer so struct events added later index - * correctly. Until upstream lands the fix, this script rewrites both sides of - * the matchstick-ts wire format to encode tuples as JSON arrays. - * - * Runs after `pnpm install`; idempotent. - */ -import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; - -const ROOT = new URL("..", import.meta.url).pathname; -const PNPM_STORE = join(ROOT, "node_modules", ".pnpm"); - -if (!existsSync(PNPM_STORE)) { - process.exit(0); -} - -const matchstickDirs = readdirSync(PNPM_STORE).filter((d) => - d.startsWith("matchstick-ts@"), -); - -let patched = 0; -for (const dir of matchstickDirs) { - const base = join(PNPM_STORE, dir, "node_modules", "matchstick-ts"); - - // --- JS-side: dist/event-capture.js (already-built artifact) --- - const jsPath = join(base, "dist", "event-capture.js"); - if (existsSync(jsPath)) { - let src = readFileSync(jsPath, "utf8"); - if (!src.includes("encodeTupleOrArray")) { - src = src.replace( - / else if \(typeof value === "object" && value !== null\) \{\s*\n\s*result\.push\(\[key, JSON\.stringify\(value\)\]\);\s*\n\s*\}/, - ` else if (typeof value === "object" && value !== null) { - result.push([key, encodeTupleOrArray(value)]); - }`, - ); - src = src.replace( - /\/\*\* Internal — shared by \{@link EventCapture\} and the log-sync ingester\. \*\/\nexport function serializeParams\(args\) \{/, - `function encodeTupleOrArray(v) { - if (typeof v === "bigint") return v.toString(); - if (Array.isArray(v)) return v.map(encodeTupleOrArray); - if (v !== null && typeof v === "object") return Object.values(v).map(encodeTupleOrArray); - return v; -} -/** Internal — shared by {@link EventCapture} and the log-sync ingester. */ -export function serializeParams(args) {`, - ); - writeFileSync(jsPath, src); - patched++; - } - } - - // --- AS-side: assembly/index.ts (recompiled by matchstick on every run) --- - const asPath = join(base, "assembly", "index.ts"); - if (existsSync(asPath)) { - let src = readFileSync(asPath, "utf8"); - if (!src.includes("JSONValueKind.ARRAY")) { - src = src.replace( - / if \(value\.kind == JSONValueKind\.BOOL\) \{\s*\n\s*return ethereum\.Value\.fromBoolean\(value\.toBool\(\)\);\s*\n\s*\}\s*\n\s*\/\/ Arrays\/objects fall back to string representation\.\s*\n\s*return ethereum\.Value\.fromString\(value\.toString\(\)\);/, - ` if (value.kind == JSONValueKind.BOOL) { - return ethereum.Value.fromBoolean(value.toBool()); - } - if (value.kind == JSONValueKind.ARRAY) { - // Treated as a Solidity tuple (struct). The TS-side serializer encodes - // both tuples and dynamic arrays as JSON arrays — the AS-generated event - // class then calls .toTuple() on the wrapper to get the struct back. - const arr = value.toArray(); - const tuple = new ethereum.Tuple(); - for (let i = 0; i < arr.length; i++) { - tuple.push(jsonValueToEthereumValue(arr[i])); - } - return ethereum.Value.fromTuple(tuple); - } - return ethereum.Value.fromString(value.toString());`, - ); - writeFileSync(asPath, src); - patched++; - } - } -} - -if (patched > 0) { - console.log(`patched matchstick-ts: ${patched} file(s) (tuple/bigint event params)`); -} From a4f5e0b90e6200f3db034d9d6199a85a5857318f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 7 Jul 2026 16:36:25 +0200 Subject: [PATCH 076/180] ci(keeper): pin sibling contract checkouts to feat/liquidate-to-im-buffer The keeper's liquidate-to-IM-buffer orchestration calls liquidatePositions (futures) and liquidatePosition(user, closeQty) (perps), which only exist on those repos' feature branches. Integration tests deploy the checked-out sibling contracts, so pinning perps/futures to feat/liquidate-to-im-buffer lets CI reproduce the local pass. Revert both refs to dev once derivatives-marketplace#73 and futures-marketplace#203 merge. --- .github/workflows/keeper-test.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 2040c95..2af4944 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -63,18 +63,23 @@ jobs: working-directory: ./contracts run: pnpm install --frozen-lockfile + # TEMPORARY: pinned to feat/liquidate-to-im-buffer because the keeper's + # liquidate-to-IM-buffer orchestration calls contract functions + # (`liquidatePositions` on futures, `liquidatePosition(user, closeQty)` on + # perps) that only exist on those feature branches. Revert both refs back + # to `dev` once derivatives-marketplace#73 and futures-marketplace#203 merge. - name: Checkout derivatives-marketplace (perps contracts) uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - ref: dev + ref: feat/liquidate-to-im-buffer path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - ref: dev + ref: feat/liquidate-to-im-buffer path: futures-marketplace - name: Install perps contracts dependencies From 57058b815d9266824104c3c50ca05078ab4b6212 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 7 Jul 2026 18:22:16 +0200 Subject: [PATCH 077/180] fix(keeper): pin ABI deps to feat/liquidate-to-im-buffer contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned derivatives-marketplace/futures-marketplace ABIs still had the pre-feature signatures (1-arg liquidatePosition, no batched liquidatePositions), so under a frozen install the keeper encoded the new liquidate-to-IM-buffer calls against the old ABIs and every liquidation tx failed to send — all integration liquidation tests timed out. Point both ABI deps at the feature branches that carry the new signatures. Revert to dev / a tagged commit once derivatives-marketplace#73 and futures-marketplace#203 merge. --- keeper/package.json | 4 ++-- keeper/pnpm-lock.yaml | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 7dfac2d..3b54b66 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,8 +21,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#0aae92cd86a01b187ea20544b6e65784b067d118&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index c4bfeba..a9ed509 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -20,11 +20,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#0aae92cd86a01b187ea20544b6e65784b067d118&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -150,8 +150,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/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-rMbMPquVdybQhDCvYVxWWYScIS+CWGA6Rcij4WX1tobHJotRNEikjovCci5mqzKFNcuoP4pJ+bHs4Df9AreYHQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c} version: 0.0.0 end-of-stream@1.4.5: @@ -166,8 +166,8 @@ 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/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-M4b+H0+c0BXoPIqBxoTwnN3BPculibODrnBKtEbYsutDK9rJfrd3vre7WINEgVz8HntySCp7K+m8bOyMF4CUgw==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175} version: 0.0.0 help-me@5.0.0: @@ -352,7 +352,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/724763fc656bb871acadcca828c0b8bb905a39ec#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/0aae92cd86a01b187ea20544b6e65784b067d118#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} From 35f9b140b6bd19fb3d43e3f6a1cf8de1dd58f7ef Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 7 Jul 2026 18:34:05 +0200 Subject: [PATCH 078/180] fix: ci typecheck --- keeper/pnpm-lock.yaml | 50 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index a9ed509..0a1becd 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: version: 1.1.10 collateral-margin-abi: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: specifier: github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) @@ -30,11 +30,11 @@ importers: version: 10.3.1 viem: specifier: ^2.48.8 - version: 2.52.2 + version: 2.54.6 devDependencies: '@types/node': specifier: ^22.0.0 - version: 22.19.21 + version: 22.20.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260511.1 version: 7.0.0-dev.20260511.1 @@ -71,8 +71,8 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@types/node@22.19.21': - resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': resolution: {integrity: sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw==} @@ -140,8 +140,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855} version: 0.0.0 colorette@2.0.20: @@ -151,7 +151,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-rMbMPquVdybQhDCvYVxWWYScIS+CWGA6Rcij4WX1tobHJotRNEikjovCci5mqzKFNcuoP4pJ+bHs4Df9AreYHQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c} version: 0.0.0 end-of-stream@1.4.5: @@ -167,7 +167,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-M4b+H0+c0BXoPIqBxoTwnN3BPculibODrnBKtEbYsutDK9rJfrd3vre7WINEgVz8HntySCp7K+m8bOyMF4CUgw==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175} version: 0.0.0 help-me@5.0.0: @@ -192,8 +192,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - ox@0.14.29: - resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + ox@0.14.30: + resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -255,8 +255,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - viem@2.52.2: - resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} + viem@2.54.6: + resolution: {integrity: sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -266,8 +266,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -305,7 +305,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@types/node@22.19.21': + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -346,7 +346,7 @@ snapshots: atomic-sleep@1.0.0: {} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/332350e6dc4bc2e65b40bc0aec6745ea8002f11b#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} colorette@2.0.20: {} @@ -368,9 +368,9 @@ snapshots: help-me@5.0.0: {} - isows@1.0.7(ws@8.20.1): + isows@1.0.7(ws@8.21.0): dependencies: - ws: 8.20.1 + ws: 8.21.0 joycon@3.1.1: {} @@ -382,7 +382,7 @@ snapshots: dependencies: wrappy: 1.0.2 - ox@0.14.29: + ox@0.14.30: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -462,16 +462,16 @@ snapshots: undici-types@6.21.0: {} - viem@2.52.2: + viem@2.54.6: dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 abitype: 1.2.3 - isows: 1.0.7(ws@8.20.1) - ox: 0.14.29 - ws: 8.20.1 + isows: 1.0.7(ws@8.21.0) + ox: 0.14.30 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -479,4 +479,4 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.21.0: {} From 0b23c3707ceb80b5d4840bc55bfaab30f5b14a30 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 8 Jul 2026 17:13:34 +0200 Subject: [PATCH 079/180] feat: align keeper + market-maker with the 1 PH/s/day contract unit Read CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY from the venues and apply the contract-size rebase to off-chain oracle prices (keeper priceFeed, MM rawOracle and the perps/futures venue adapters), matching on-chain getMarketPrice(). Remove the deliveryDurationDays multiplier from the keeper predict/solve/snapshot layers and the futures venue. PME getNetPositionDelta comment clarified (per-day delta, no duration factor). Keeper unit + integration tests updated. --- contracts/contracts/PortfolioMarginEngine.sol | 3 +- keeper/README.md | 4 +- keeper/scripts/debug-delivery-bootstrap.ts | 10 +- keeper/src/oracle/priceFeed.ts | 39 ++- keeper/src/predict/mm.ts | 13 +- keeper/src/predict/snapshot.ts | 9 +- keeper/src/predict/solve.ts | 16 +- keeper/src/predict/types.ts | 12 +- keeper/src/venues/futures.ts | 36 +-- keeper/tests/delivery/coordinator.test.ts | 5 +- keeper/tests/integration/deployStack.ts | 52 ++- .../integration/keeper.integration.test.ts | 46 +-- keeper/tests/integration/scenarios.ts | 300 ++++++++++-------- keeper/tests/oracle/priceFeed.test.ts | 8 + keeper/tests/predict/coordinator.test.ts | 7 +- .../tests/predict/coordinatorAlerts.test.ts | 7 +- keeper/tests/predict/mm.test.ts | 34 +- keeper/tests/predict/snapshot.test.ts | 4 - keeper/tests/predict/solve.test.ts | 19 +- keeper/tests/predict/solveTarget.test.ts | 45 +-- keeper/tests/venues/futures.test.ts | 51 +-- keeper/tests/venues/reduceToTarget.test.ts | 25 +- market-maker/src/adapters/futures/venue.ts | 295 ++++++++++------- market-maker/src/adapters/perps/venue.ts | 168 +++++----- market-maker/src/core/rawOracle.ts | 23 +- 25 files changed, 697 insertions(+), 534 deletions(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index bf64c97..2e4c2ca 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -194,7 +194,8 @@ contract PortfolioMarginEngine is netDelta += pos.netQuantity * int256(WAD) / qtyScale; } - // Futures delta: sum(deliveryDurationDays * qty) * WAD per active position (optional) + // Futures delta: sum(±qty) * WAD per active position — one WAD per contract + // (1 PH/s/day), sign per side. No duration multiplier. (optional) if (address(futures) != address(0)) { netDelta += futures.getNetPositionDelta(user); } diff --git a/keeper/README.md b/keeper/README.md index addbf19..ecba36e 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -216,14 +216,14 @@ Suites cover: - `pme/health` — multicall batching + `imUtilization` precision - `venues/perps` — long/short PnL math, `PERPS_MARKET_ID` sentinel, position id -- `venues/futures` — buyer/seller PnL, `deliveryAt` → marketId, `deliveryDurationDays` caching +- `venues/futures` — buyer/seller PnL (one contract = 1 PH/s·day, duration-free), `deliveryAt` → marketId - `coordinator/queue` — BigInt-safe ordering, `upsert` re-ranking, snapshot semantics - `coordinator/planner` — orders-leg, position ranking, `OrdersStillOpen`-replay, bad-debt - `alert/notifier` — dedupe window, severity promotion, ordering, retry-on-failure - `discovery/tracker` — checksum dedupe, `onAdded` / `onChanged` listeners, startup backfill - `discovery/webhook` — payload extraction across `data` / `records` / array shapes - `runtime/scheduler` — alert ladder thresholds, queue upsert + executor kick wiring -- `oracle/priceFeed` — rebase to token decimals, dispatch, no-op on unchanged answer +- `oracle/priceFeed` — rebase to token decimals + contract-size unit (`CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY`, ×10 at defaults), dispatch, no-op on unchanged answer - `predict/mm` — net delta, stress, perp/futures unrealized loss, mm/im surplus - `predict/solve` — long/short downside & upside thresholds, drag from orderMargin/funding - `predict/predictiveIndex` — upsert/invalidate, sorted crossings on rise & drop diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index 8600441..b3ef811 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -73,15 +73,15 @@ const now = BigInt(Math.floor(Date.now() / 1000)); const block = await client.getBlock(); console.log("wall-clock now:", now, " block.timestamp:", block.timestamp); -const deliveryDurationDays = (await client.readContract({ +const expirationIntervalDays = (await client.readContract({ address: FUTURES, abi: FuturesAbi, - functionName: "deliveryDurationDays", + functionName: "expirationIntervalDays", })) as number; -const window = BigInt(deliveryDurationDays) * 86_400n; +const window = BigInt(expirationIntervalDays) * 86_400n; console.log( - "deliveryDurationDays:", - deliveryDurationDays, + "expirationIntervalDays:", + expirationIntervalDays, "→ window:", window, "s", diff --git a/keeper/src/oracle/priceFeed.ts b/keeper/src/oracle/priceFeed.ts index 76f3443..f262744 100644 --- a/keeper/src/oracle/priceFeed.ts +++ b/keeper/src/oracle/priceFeed.ts @@ -1,3 +1,4 @@ +import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; @@ -40,6 +41,12 @@ export type PriceListener = (update: PriceUpdate) => void; * we rescale to the perps/futures token decimals (USDC = 6) so consumers * compare apples to apples with `getMarketPrice()`. * + * The oracle quotes the price of `ORACLE_UNIT_HPS_DAY` (100 TH/s over a day), but the + * venues denominate one contract in `contractSizeHpsDay` (default 1e15 = 1 PH/s over a + * day). We read that contract-size multiplier from the perps venue once at `start()` and + * apply `contractSizeHpsDay / ORACLE_UNIT_HPS_DAY` so the streamed price matches on-chain + * `getMarketPrice()`. Both venues are assumed to share the same contract size. + * * Lifecycle: * - `start()`: read decimals, prime `current` via one `latestRoundData`, * then attach the watcher. Returns once the first read has resolved. @@ -53,6 +60,10 @@ export class PriceFeed { private unwatch: (() => void) | undefined; /** 10^(oracleDecimals - tokenDecimals). Set during `start()`. */ private rescaleDivisor: bigint = 1n; + /** Contract size in hashes/s·day (`contractSizeHpsDay`). Set during `start()`. */ + private contractSizeHpsDay: bigint = 1n; + /** Oracle quote basis in hashes/s·day (`ORACLE_UNIT_HPS_DAY`). Set during `start()`. */ + private oracleUnitHpsDay: bigint = 1n; private readonly chain: Chain; private readonly config: Config; @@ -91,6 +102,28 @@ export class PriceFeed { } this.rescaleDivisor = 10n ** BigInt(oracleDecimals - this.tokenDecimals); + // Rebase from the oracle's quote basis (100 TH/s/day) to one contract unit + // (contractSizeHpsDay/day), matching `getMarketPrice()` on-chain. + const [contractSizeHpsDay, oracleUnitHpsDay] = await Promise.all([ + this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }) as Promise, + ]); + if (contractSizeHpsDay <= 0n || oracleUnitHpsDay <= 0n) { + throw new Error( + `PriceFeed: invalid contract size (contractSizeHpsDay=${contractSizeHpsDay}, ORACLE_UNIT_HPS_DAY=${oracleUnitHpsDay})`, + ); + } + this.contractSizeHpsDay = contractSizeHpsDay; + this.oracleUnitHpsDay = oracleUnitHpsDay; + await this.refresh("start"); // We watch BTC/USDC (not HashpriceUSD) because HashpriceUSD is a pure @@ -114,6 +147,8 @@ export class PriceFeed { btcUsdcFeed: this.config.oracle.btcUsdcFeedAddress, oracleDecimals, tokenDecimals: this.tokenDecimals, + contractSizeHpsDay: this.contractSizeHpsDay, + oracleUnitHpsDay: this.oracleUnitHpsDay, currentPrice: this.currentPrice, }, "PriceFeed started", @@ -164,7 +199,9 @@ export class PriceFeed { return; } - const next = answer / this.rescaleDivisor; + // Mirror on-chain `getMarketPrice()`: rebase decimals first, then apply the + // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). + const next = ((answer / this.rescaleDivisor) * this.contractSizeHpsDay) / this.oracleUnitHpsDay; const prev = this.currentPrice; if (prev === next) return; diff --git a/keeper/src/predict/mm.ts b/keeper/src/predict/mm.ts index 5b56c97..5e37f9a 100644 --- a/keeper/src/predict/mm.ts +++ b/keeper/src/predict/mm.ts @@ -18,7 +18,7 @@ import type { AccountSnapshot, MMParams } from "./types.ts"; * - perp.orderMargin (constant) * - perp.unrealizedLoss = max(0, -((P - entry) * netQty / qtyScale)) * - futures.orderMargin (constant) - * - futures.unrealizedLoss = sum_i max(0, -(buyer? : ±)(P - entry_i)*deliveryDays) + * - futures.unrealizedLoss = sum_i max(0, -(buyer? : ±)(P - entry_i)) * - perp.fundingOwed (constant — short-term, refreshed on snapshot) * * Total mmRequired(P) is therefore piecewise-linear with kinks at the @@ -40,7 +40,7 @@ function abs(x: bigint): bigint { * Aggregate net delta in WAD (matches `_aggregateGreeks` for pure-delta). * * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals - * futuresDelta = sum_i (isBuyer ? +1 : -1) * deliveryDays * WAD + * futuresDelta = sum_i (isBuyer ? +1 : -1) * WAD * * Note: the on-chain `getNetPositionDelta` already returns this sum for the * futures leg in WAD; we re-derive it here off-chain because the snapshot @@ -52,7 +52,7 @@ export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { let delta = (snap.perp.netQty * WAD) / perpQtyScale; for (const pos of snap.futures.positions) { const sign = pos.isBuyer ? 1n : -1n; - delta += sign * snap.futures.deliveryDays * WAD; + delta += sign * WAD; } return delta; } @@ -99,17 +99,18 @@ export function perpUnrealizedLoss(snap: AccountSnapshot, params: MMParams, P: b } /** - * Sum of per-position futures unrealized losses at price P. Each contract: + * Sum of per-position futures unrealized losses at price P. Each contract + * settles `pricePerDay` of notional (no duration multiplier): * * diffPerDay = isBuyer ? (P - entryPerDay) : (entryPerDay - P) - * pnl = diffPerDay * deliveryDays + * pnl = diffPerDay * loss = max(0, -pnl) */ export function futuresUnrealizedLoss(snap: AccountSnapshot, P: bigint): bigint { let sum = 0n; for (const pos of snap.futures.positions) { const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay * snap.futures.deliveryDays; + const pnl = diffPerDay; if (pnl < 0n) sum += -pnl; } return sum; diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 4afbcbd..d62f35f 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -56,7 +56,7 @@ export async function readMMParams( * function of price. Two RPC round-trips: * * 1. Bulk multicall: balance, perp position/orderMargin/funding, - * futures orderMargin/positionIds, deliveryDurationDays. + * futures orderMargin/positionIds. * 2. Per-position multicall: hydrate each futures position so we know its * `(buyer, buyPricePerDay, sellPricePerDay)` for off-chain PnL. * @@ -75,7 +75,6 @@ export async function readAccountSnapshot( perpFunding, futuresOrderMargin, futuresPositionIds, - deliveryDurationDays, ] = await chain.publicClient.multicall({ contracts: [ { @@ -114,11 +113,6 @@ export async function readAccountSnapshot( functionName: "getPositionIds" as const, args: [user] as const, }, - { - address: config.futures.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays" as const, - }, ] as const, allowFailure: false, }); @@ -164,7 +158,6 @@ export async function readAccountSnapshot( futures: { positions: futuresPositions, orderMargin: futuresOrderMargin as bigint, - deliveryDays: BigInt(deliveryDurationDays as number), }, }; } diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index 17c3abe..01ef95e 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -208,7 +208,7 @@ export function simulateFuturesClose( const diffPerDay = pos.isBuyer ? currentPrice - pos.entryPricePerDay : pos.entryPricePerDay - currentPrice; - const pnl = diffPerDay * snap.futures.deliveryDays; + const pnl = diffPerDay; balanceDelta += pnl - liquidationFee; } return { @@ -281,11 +281,7 @@ export function solveFuturesLotsToTarget( // shapes WHICH lots that tx closes. The prefix search below is unchanged, so // we still stop at the deepest in-band subset (reaching IM stays the // priority — balance is best-effort within that). - const ranked = rankLotsBalancedAcrossExpirations( - positions, - snap.futures.deliveryDays, - currentPrice, - ); + const ranked = rankLotsBalancedAcrossExpirations(positions, currentPrice); const n = ranked.length; let best: Hex[] | undefined; @@ -394,11 +390,10 @@ type FuturesLot = AccountSnapshot["futures"]["positions"][number]; */ function rankLotsBalancedAcrossExpirations( positions: readonly FuturesLot[], - deliveryDays: bigint, currentPrice: bigint, ): FuturesLot[] { - const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, deliveryDays, currentPrice); - const notionalOf = (p: FuturesLot) => p.entryPricePerDay * deliveryDays; + const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, currentPrice); + const notionalOf = (p: FuturesLot) => p.entryPricePerDay; const groups = new Map(); for (const p of positions) { @@ -442,11 +437,10 @@ function rankLotsBalancedAcrossExpirations( /** Per-lot unrealized loss at `P` (token decimals); 0 when in profit. */ function lotUnrealizedLoss( pos: AccountSnapshot["futures"]["positions"][number], - deliveryDays: bigint, P: bigint, ): bigint { const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay * deliveryDays; + const pnl = diffPerDay; return pnl < 0n ? -pnl : 0n; } diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts index 00a5537..1d5b201 100644 --- a/keeper/src/predict/types.ts +++ b/keeper/src/predict/types.ts @@ -31,9 +31,10 @@ export interface AccountSnapshot { }; /** - * One entry per active futures position. Each contract is a single unit; - * PnL accrues `(P_perDay - entryPricePerDay) × deliveryDays` from the - * holder's perspective (`+` for buyers, `−` for sellers). + * One entry per active futures position. Each contract is a single unit that + * settles `pricePerDay` of notional (no duration multiplier); PnL accrues + * `(P_perDay - entryPricePerDay)` from the holder's perspective (`+` for + * buyers, `−` for sellers). */ futures: { positions: Array<{ @@ -46,15 +47,12 @@ export interface AccountSnapshot { * a `deliveryAt` are the same market/order-book; the liquidation solver * groups on it to balance closures across expirations rather than * draining one expiry's book. It does NOT affect PnL/margin math — every - * lot is valued with the single global `deliveryDays` (mirroring the - * on-chain `deliveryDurationDays`). + * lot contributes a single unit. */ deliveryAt: bigint; }>; /** Constant in P: `getFuturesOrderMargin(user)`. */ orderMargin: bigint; - /** Same delivery duration applies to every active position. */ - deliveryDays: bigint; }; } diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index cfbb9e7..550212e 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -20,11 +20,9 @@ import type { /** * `Venue` adapter for the Futures contract. * - * Caches `deliveryDurationDays` lazily on first use: the contract setting - * is immutable within an epoch and only ever ratchets on admin action, so - * we read it once per process and re-read after restart. Position PnL math - * uses this value as a multiplier (`priceDiffPerDay * deliveryDurationDays`) - * — caching it keeps `readPositions` to one RPC + one multicall. + * One matched unit settles `pricePerDay` of notional (there is no duration + * multiplier). Position PnL is therefore just `priceDiffPerDay` per contract, + * mirroring `getFuturesUnrealizedPnl` on-chain. */ export class FuturesVenue implements Venue { readonly name = "futures" as const; @@ -33,7 +31,6 @@ export class FuturesVenue implements Venue { private readonly config: Config; private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; - private deliveryDurationDays: bigint | undefined; private mmParams: MMParams | undefined; constructor( @@ -89,7 +86,7 @@ export class FuturesVenue implements Venue { } async readPositions(user: Address): Promise { - const [positionIds, marketPrice, deliveryDurationDays] = await Promise.all([ + const [positionIds, marketPrice] = await Promise.all([ this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, @@ -101,7 +98,6 @@ export class FuturesVenue implements Venue { abi: FuturesAbi, functionName: "getMarketPrice", }) as Promise, - this.getDeliveryDurationDays(), ]); if (positionIds.length === 0) return []; @@ -119,8 +115,8 @@ export class FuturesVenue implements Venue { const userAddr = getAddress(user); return positionIds.map((id, i) => { const pos = positions[i]; - // Each position is a single contract; PnL accrues per day across the - // full delivery window (matches `getFuturesUnrealizedPnl` on-chain). + // Each position is a single contract that settles `pricePerDay` of notional + // (no duration multiplier), matching `getFuturesUnrealizedPnl` on-chain. const isBuyer = getAddress(pos.buyer) === userAddr; const entryPricePerDay = isBuyer ? pos.buyPricePerDay @@ -128,9 +124,9 @@ export class FuturesVenue implements Venue { const priceDiffPerDay = isBuyer ? marketPrice - entryPricePerDay // long: lose when market drops : entryPricePerDay - marketPrice; // short: lose when market rises - const pnl = priceDiffPerDay * deliveryDurationDays; + const pnl = priceDiffPerDay; const unrealizedLoss = pnl < 0n ? -pnl : 0n; - const notional = entryPricePerDay * deliveryDurationDays; + const notional = entryPricePerDay; return { id, @@ -235,22 +231,6 @@ export class FuturesVenue implements Venue { return this.mmParams; } - /** - * Read `deliveryDurationDays` lazily and cache it. The contract returns - * `uint8` (decoded as `number`); we widen to `bigint` so downstream - * arithmetic stays in bigint land. - */ - private async getDeliveryDurationDays(): Promise { - if (this.deliveryDurationDays !== undefined) - return this.deliveryDurationDays; - const days = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - })) as number; - this.deliveryDurationDays = BigInt(days); - return this.deliveryDurationDays; - } } /** `bytes32(uint256(deliveryAt))` — same encoding the indexer uses. */ diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 49262ed..f4d8981 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -94,8 +94,6 @@ function lotClosedLog(lotId: Hex): LotClosedLog { interface ChainStubOptions { /** Fixed `block.timestamp`-style number returned by `getBlockNumber`. */ blockNumber?: bigint; - /** Value `deliveryDurationDays` returns (uint8 → number). Defaults to 7. */ - deliveryDurationDays?: number; /** Recorded calls to `simulateContract`. The handler is per-call so tests can vary outcomes. */ simulate?: (args: readonly unknown[]) => { request: { ok: true } } | { error: unknown }; writeHash?: `0x${string}`; @@ -148,7 +146,6 @@ function makeChain(opts: ChainStubOptions = {}): Chain & { functionName: string; args?: readonly unknown[]; }) => { - if (functionName === "deliveryDurationDays") return opts.deliveryDurationDays ?? 7; // start()'s pre-flight asserts the keeper signer == validator. Default // matches `VALIDATOR` (the chain stub's account.address), so existing // tests don't need to opt into anything. Set `validator: 0x...other` @@ -907,7 +904,7 @@ describe("DeliveryCoordinator: view-based discovery", () => { const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coordinator.start(); // Force getPositionIds to blow up *after* startup (which uses readContract - // for `deliveryDurationDays`). The listener path must never throw — + // for `validatorAddress`). The listener path must never throw — // bubbling out would crash the tracker's onAdded fan-out. chain.publicClient.readContract = (async () => { throw new Error("rpc down"); diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 86e8070..e9c9c2e 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -92,7 +92,20 @@ export interface DeployedStack { config: { tokenDecimals: number; oracleDecimals: number; + /** + * Raw hashprice oracle answer (per 100 TH/s·day, i.e. `ORACLE_UNIT_HPS_DAY`). + * Both venues rebase this to a per-contract mark via + * `market = answer × CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY` (= ×10), + * so this seed is `initialMarketPrice / 10`. Fixtures that need to re-post + * the oracle (e.g. delivery settlement) write this value directly. + */ initialHashprice: bigint; + /** + * Per-contract mark at deploy time (= `initialHashprice × 10`). This is the + * unit orders and positions are denominated in — scenarios use it as the + * at-the-money entry price. + */ + initialMarketPrice: bigint; initialBtcUsdc: bigint; minimumPriceIncrement: bigint; quantityDecimals: number; @@ -101,7 +114,6 @@ export interface DeployedStack { perpsMakerFeeBps: bigint; futuresTakerFee: bigint; futuresLiquidationFee: bigint; - futuresDeliveryDurationDays: number; futuresFirstDeliveryDate: bigint; insuranceFund: bigint; initialUserBalance: bigint; @@ -112,8 +124,25 @@ const TOKEN_DECIMALS = 6; const ORACLE_DECIMALS = 6; const QUANTITY_DECIMALS = 6; -/** Hashprice = $4.21 / 100 TH/s / day (recent Braiins index), 6 decimals. */ -const INITIAL_HASHPRICE = parseUnits("4.21", ORACLE_DECIMALS); +/** + * Ratio by which both venues rebase the oracle answer into a per-contract mark: + * `CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY = 1e15 / 1e14 = 10`. The oracle + * quotes 100 TH/s·day; one contract settles 1 PH/s·day, so the mark is ×10 the + * raw answer. Exported so scenarios convert market prices → oracle answers in + * one place. + */ +export const ORACLE_TO_MARKET_MULTIPLIER = 10n; + +/** + * Per-contract mark at deploy time. Positions and orders are denominated in this + * (contract) unit; the oracle answer is seeded at `/ ORACLE_TO_MARKET_MULTIPLIER` + * so `getMarketPrice()` (answer × 10) lands back here. Kept at $4.21 so the + * pre-existing perps fixtures (which never carried the duration factor) keep + * their dollar sizing unchanged. + */ +const INITIAL_MARKET_PRICE = parseUnits("4.21", TOKEN_DECIMALS); +/** Raw hashprice oracle answer (per 100 TH/s·day) — rebased ×10 into the mark above. */ +const INITIAL_HASHPRICE = INITIAL_MARKET_PRICE / ORACLE_TO_MARKET_MULTIPLIER; /** Reference BTC/USDC mid-price; only the *delta* matters for predictor tests. */ const INITIAL_BTC_USDC = parseUnits("65000", ORACLE_DECIMALS); @@ -124,10 +153,9 @@ const PERPS_MAKER_FEE_BPS = 0n; const FUTURES_TAKER_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_MARGIN_PCT = 20; -const FUTURES_DELIVERY_DURATION_DAYS = 7; -const FUTURES_DELIVERY_INTERVAL_DAYS = 7; +/** Spacing, in days, between successive expiries (renamed from delivery interval). */ +const FUTURES_EXPIRATION_INTERVAL_DAYS = 7; const FUTURES_FUTURE_DELIVERY_DATES_COUNT = 10; -const FUTURES_SPEED_HPS = parseUnits("100", 12); const INSURANCE_FUND = parseUnits("100000", TOKEN_DECIMALS); const INITIAL_USER_BALANCE = parseUnits("10000", TOKEN_DECIMALS); @@ -223,8 +251,12 @@ export async function deployStack(rpcUrl: string): Promise { vault, ]); const latestBlock = await publicClient.getBlock(); + // First expiry sits one interval out from now (the duration constant is gone — + // hashpower settles per-day, so only the expiry spacing schedules the book). const firstDeliveryDate = - latestBlock.timestamp + BigInt(FUTURES_DELIVERY_DURATION_DAYS * 24 * 3600); + latestBlock.timestamp + BigInt(FUTURES_EXPIRATION_INTERVAL_DAYS * 24 * 3600); + // initialize(hashrateOracle, liquidationMarginPercent, minimumPriceIncrement, + // expirationIntervalDays, futureDeliveryDatesCount, firstFutureDeliveryDate) const futures = await deployProxy( publicClient, owner.client, @@ -234,10 +266,8 @@ export async function deployStack(rpcUrl: string): Promise { [ hashpriceOracle, FUTURES_LIQUIDATION_MARGIN_PCT, - FUTURES_SPEED_HPS, MIN_PRICE_INCREMENT, - FUTURES_DELIVERY_DURATION_DAYS, - FUTURES_DELIVERY_INTERVAL_DAYS, + FUTURES_EXPIRATION_INTERVAL_DAYS, FUTURES_FUTURE_DELIVERY_DATES_COUNT, firstDeliveryDate, ], @@ -389,6 +419,7 @@ export async function deployStack(rpcUrl: string): Promise { tokenDecimals: TOKEN_DECIMALS, oracleDecimals: ORACLE_DECIMALS, initialHashprice: INITIAL_HASHPRICE, + initialMarketPrice: INITIAL_MARKET_PRICE, initialBtcUsdc: INITIAL_BTC_USDC, minimumPriceIncrement: MIN_PRICE_INCREMENT, quantityDecimals: QUANTITY_DECIMALS, @@ -397,7 +428,6 @@ export async function deployStack(rpcUrl: string): Promise { perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, futuresTakerFee: FUTURES_TAKER_FEE, futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, - futuresDeliveryDurationDays: FUTURES_DELIVERY_DURATION_DAYS, futuresFirstDeliveryDate: firstDeliveryDate, insuranceFund: INSURANCE_FUND, initialUserBalance: INITIAL_USER_BALANCE, diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 06029e1..692ff35 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -307,8 +307,9 @@ describe("Futures liquidation", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds a single long futures contract at the - // first delivery date. The unrealized loss is `(entryPrice − marketPrice) - // · deliveryDurationDays · qty`; sized so the deposit can't cover it. + // first delivery date. Duration-free: the unrealized loss is + // `(entryPrice − marketPrice) · qty` (multiplier of 1); sized so the + // deposit can't cover it. const ctx = await loadFixture(futuresLongCrashFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); @@ -382,7 +383,7 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds 12 long futures lots; a moderate crash - // (4.21 → 3.90) breaks MM but a subset close restores the IM buffer. + // ($40 → $30 mark) breaks MM but a subset close restores the IM buffer. // Contract under test (the screenshot bug fix): the planner must NOT // fan out into one-lot-per-tx churn. Instead a single // `liquidatePositions(user, ids[])` closes the worst-first subset in @@ -432,10 +433,10 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds 6 long futures lots on EACH of two delivery - // dates (12 total). The moderate crash (4.21 → 3.90) breaks MM; because - // the risk model weights every lot by the same global - // `deliveryDurationDays`, the aggregate margin equals the single-expiry - // 12-lot case, so a worst-first subset restores the IM buffer. + // dates (12 total). The moderate crash ($40 → $30 mark) breaks MM; because + // the duration-free risk model weights every lot by the same per-day value + // (±1 delta each) regardless of expiry, the aggregate margin equals the + // single-expiry 12-lot case, so a worst-first subset restores the IM buffer. // // Contract under test (the balancing feature): the ONE // `liquidatePositions(user, ids[])` call must draw its closed lots from @@ -545,9 +546,9 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice is long 40 perps AND long 1 futures lot; a moderate - // crash (4.21 → 3.00) puts the *combined* portfolio below MM. The perps - // leg dominates by unrealized loss ($48.40 vs $8.47), so the planner - // reduces it first. + // crash (4.21 → 3.00 mark) puts the *combined* portfolio below MM. The + // perps leg dominates by unrealized loss ($48.40 vs $1.21, duration-free), + // so the planner reduces it first. // // Contract under test: the perps `reduceToTarget` sizes its partial // `closeQty` against WHOLE-portfolio margin — the still-open futures leg's @@ -598,11 +599,12 @@ describe("Liquidate down to the IM buffer", () => { "cross-venue: a substantially underwater account is swept on BOTH venues into the [MM, IM] band", { timeout: 60_000 }, async () => { - // Precondition: alice is long 6 futures lots AND long 25 perps; a moderate - // crash (4.21 → 3.00) leaves the combined portfolio SUBSTANTIALLY under MM - // (~$8.12 deficit). The futures leg dominates by loss, so it's reduced - // first — but fully closing all 6 lots only frees ~$6.30 of MM stress, - // short of the deficit, so the account is still under MM. + // Precondition: alice is long 12 futures lots AND long 11 perps (staged at + // a $40 mark); a moderate crash ($40 → $30 mark) leaves the combined + // portfolio SUBSTANTIALLY under MM (~$14.50 deficit). The futures leg + // dominates by loss ($120 vs $110), so it's reduced first — but fully + // closing all 12 lots realizes $120 of loss + $12 fee, still short of the + // residual perps MM requirement, so the account is still under MM. // // Contract under test: the planner's position loop must then take a // SECOND iteration and reduce the perps leg (partial, continuous qty) to @@ -616,12 +618,12 @@ describe("Liquidate down to the IM buffer", () => { const alice = ctx.accounts.alice.account.address; const perpsBefore = await readPerpsPosition(ctx, alice); - assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 25 perps"); + assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 11 perps"); const futuresBefore = await readFuturesPositionIds(ctx, alice); assert.equal( futuresBefore.length, ctx.aliceFuturesQty, - "precondition: alice holds 6 futures lots", + "precondition: alice holds 12 futures lots", ); await ctx.makeLiquidatable(); @@ -717,8 +719,8 @@ describe("Cross-venue coordination", () => { { timeout: 60_000 }, async () => { // Precondition: 100-qty perps long ($420 loss) + 1-unit futures - // long ($29.40 loss). The planner's `rankPositions` orders by - // `unrealizedLoss DESC`, so perps must be closed strictly before + // long ($4.20 loss, duration-free). The planner's `rankPositions` orders + // by `unrealizedLoss DESC`, so perps must be closed strictly before // futures. Observable signal: the block number of the perps // `PositionLiquidated` event is strictly less than the futures one. const ctx = await loadFixture(crossVenuePerpsDominantFixture, testClient); @@ -748,9 +750,9 @@ describe("Cross-venue coordination", () => { { timeout: 60_000 }, async () => { // Precondition: inverted from the previous test — 1-qty perps long - // ($4.20 loss) + 20-unit futures long ($588 loss across the 7-day - // delivery window). Futures must be closed strictly before perps, - // confirming the planner's ranking is by loss size and not by a + // ($4.20 loss) + 12-unit futures long ($50.40 loss, duration-free: + // 12 · ($4.21 − $0.01 mark)). Futures must be closed strictly before + // perps, confirming the planner's ranking is by loss size and not by a // hard-coded venue order. const ctx = await loadFixture(crossVenueFuturesDominantFixture, testClient); keeper = buildKeeper(ctx); diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 3282a64..68953fc 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -1,6 +1,11 @@ import { parseUnits, type Address } from "viem"; import { hardhat } from "viem/chains"; -import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; +import { + deployStack, + ORACLE_TO_MARKET_MULTIPLIER, + type DeployedStack, + type Wallet, +} from "./deployStack.ts"; /** * Fixture builders. @@ -31,21 +36,31 @@ import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; // ───────────────────────────────────────────────────────────────────────── export interface BaseFixture extends DeployedStack { + /** Write a raw hashprice *oracle answer* (per 100 TH/s·day). */ bumpHashprice(newPrice: bigint): Promise; bumpBtcUsdc(newPrice: bigint): Promise; + /** + * Set the per-contract *mark* (`getMarketPrice()` value). Internally divides + * by `ORACLE_TO_MARKET_MULTIPLIER` (×10 rebase) before writing the oracle, so + * callers can reason in the same contract unit that orders/positions use. + * Does not touch BTC/USDC — used to stage a fixture's at-the-money entry mark. + */ + setMark(marketPrice: bigint): Promise; /** Deposit USDC into the vault from the given (test-known) wallet. */ deposit(userAddr: Address, amount: bigint): Promise; /** - * Apply a fresh hashprice and a *paired* BTC/USDC tick. The predictor - * only listens to the BTC/USDC channel, so the second write is what - * makes the event-driven liquidation path observable; the hashprice - * write is what actually moves PnL. + * Apply a fresh mark and a *paired* BTC/USDC tick. The predictor only listens + * to the BTC/USDC channel, so the second write is what makes the event-driven + * liquidation path observable; the mark write is what actually moves PnL. + * + * The argument is a per-contract *mark* (contract unit), rebased ×10 down to + * the oracle answer internally — the same unit as entry/order prices. * * `crashOracles` moves BTC/USDC *down* (long-side loss); `pumpOracles` * moves it *up* (short-side loss). */ - crashOracles(hashpricePrice: bigint): Promise; - pumpOracles(hashpricePrice: bigint): Promise; + crashOracles(marketPrice: bigint): Promise; + pumpOracles(marketPrice: bigint): Promise; } export interface AliceDepositFixture extends BaseFixture { @@ -132,10 +147,10 @@ export interface PerpsPartialCrashFixture extends BaseFixture { * Alice holds equal-size futures long books on TWO delivery dates (separate * markets) and takes the same *moderate* crash as `FuturesPartialCrashFixture`. * A subset close restores the IM buffer — and because every lot carries the - * same global `deliveryDurationDays` risk weight, the aggregate margin matches - * the single-expiry 12-lot case. Used to prove the keeper's ONE - * `liquidatePositions` tx spreads the close *across both expirations* instead - * of draining one book first. + * same per-day risk weight (duration-free, ±1 delta each) regardless of expiry, + * the aggregate margin matches the single-expiry 12-lot case. Used to prove the + * keeper's ONE `liquidatePositions` tx spreads the close *across both + * expirations* instead of draining one book first. */ export interface MultiExpiryFuturesPartialCrashFixture extends BaseFixture { aliceDeposit: bigint; @@ -174,20 +189,27 @@ export interface CrossVenueOrdersAndPositionsFixture extends CrossVenueFixture { // Base fixture // ───────────────────────────────────────────────────────────────────────── +/** Convert a per-contract mark into the raw oracle answer the venues rebase ×10. */ +function markToOracle(marketPrice: bigint): bigint { + return marketPrice / ORACLE_TO_MARKET_MULTIPLIER; +} + export async function baseFixture(rpcUrl: string): Promise { const stack = await deployStack(rpcUrl); return { ...stack, bumpHashprice: (price) => writeOracle(stack, stack.addresses.hashpriceOracle, price), bumpBtcUsdc: (price) => writeOracle(stack, stack.addresses.btcUsdcFeed, price), + setMark: (marketPrice) => + writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)), deposit: (user, amount) => depositTo(stack, user, amount), - crashOracles: async (hashpricePrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + crashOracles: async (marketPrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); const movedBtc = (stack.config.initialBtcUsdc * 9n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, - pumpOracles: async (hashpricePrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + pumpOracles: async (marketPrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); const movedBtc = (stack.config.initialBtcUsdc * 11n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, @@ -233,7 +255,7 @@ export function perpsLongCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -272,7 +294,7 @@ export function perpsShortCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.bob, seller: base.accounts.alice, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -312,11 +334,11 @@ export function twoUnderwaterUsersFixtureBuilder(rpcUrl: string) { await placePerpsOrder( base, base.accounts.bob, - base.config.initialHashprice, + base.config.initialMarketPrice, -(aliceQty + daveQty), ); - await placePerpsOrder(base, base.accounts.alice, base.config.initialHashprice, aliceQty); - await placePerpsOrder(base, base.accounts.dave, base.config.initialHashprice, daveQty); + await placePerpsOrder(base, base.accounts.alice, base.config.initialMarketPrice, aliceQty); + await placePerpsOrder(base, base.accounts.dave, base.config.initialMarketPrice, daveQty); return { ...base, @@ -359,7 +381,7 @@ export function perpsOrdersAndPositionFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -380,15 +402,17 @@ export function perpsOrdersAndPositionFixtureBuilder(rpcUrl: string) { /** * Alice holds a long futures contract at the first delivery date; Bob is - * the matched seller. Position PnL accrues per day across the full - * delivery window: at entry 4.21 / day × 7 days = $29.47 notional per - * unit. A crash to 0.01 puts ($4.20 × 7) = $29.40 of unrealized loss per - * unit — sized so 12 units exceed Alice's $200 deposit. + * the matched seller. Duration-free model: one contract settles the per-day + * value with a multiplier of 1 (no × delivery window), so at the $4.21 mark + * each unit carries $4.21 of notional. A crash to a $0.01 mark inflicts + * ($4.21 − $0.01) = $4.20 of unrealized loss per unit → 12 units = $50.40, + * far exceeding Alice's post-fee balance ($40 − $12 taker fee = $28) so the + * account is deeply underwater and fully liquidates into bad debt. */ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("2000", base.config.tokenDecimals); const aliceFuturesQty = 12; @@ -398,7 +422,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -408,7 +432,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, aliceFuturesQty, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } @@ -426,7 +450,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("2000", base.config.tokenDecimals); const aliceFuturesQty = 12; @@ -436,14 +460,14 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); - // Stale buy order well below the current mark — no counterparty + // Stale buy order well below the current $4.21 mark — no counterparty // exists at this price level so the order rests on the book. - const restingPrice = parseUnits("2.00", base.config.oracleDecimals); + const restingPrice = parseUnits("2.00", base.config.tokenDecimals); await placeFuturesOrder( base, base.accounts.alice, @@ -458,7 +482,7 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { aliceFuturesQty, restingOrderCount: 1, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } @@ -471,11 +495,11 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { export function multiFuturesFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const firstDeliveryAt = base.config.futuresFirstDeliveryDate; const secondDeliveryAt = - firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_DELIVERY_INTERVAL_DAYS`. + firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_EXPIRATION_INTERVAL_DAYS`. await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); @@ -484,7 +508,7 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt, quantity: 6, }); @@ -495,38 +519,49 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { aliceDeposit, deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } /** - * Alice holds 12 long futures lots at the first delivery date; a moderate - * hashprice crash (4.21 → 3.90 / 100 TH/s / day) drives her below MM while - * leaving enough headroom that closing a worst-first subset of lots restores - * `balance >= IM`. Sizing (deliveryDurationDays = 7, PME shocks 10% IM / 5% - * MM, $1 flat liquidation fee): - * - unrealized loss / lot after crash ≈ (4.21 − 3.90) · 7 = $2.17 - * - MM stress / lot ≈ 0.05 · 3.90 · 7 = $1.365, IM stress ≈ $2.73 - * - MM_req₀ ≈ 12 · (1.365 + 2.17) = $42.42 > $40 deposit ⇒ underwater - * - closing ~7–10 lots frees enough MM stress to re-cross MM while staying - * at/under IM (the rest stay open) — a genuine partial liquidation. - * Deposit $40 also clears the entry IM (12 · 0.10 · 4.21 · 7 ... perp-free - * futures IM ≈ $35.36) so Alice can open the position pre-crash. + * Alice holds 12 long futures lots at the first delivery date; a moderate crash + * drives her below MM while leaving enough headroom that closing a worst-first + * subset of lots restores `balance >= IM`. + * + * Duration-free rescale (mirrors the unit `solveTarget` fixture): each contract + * settles the per-day value ×1 (no ×7 window), so a shallow $4.21→$3.90 move no + * longer clears the flat $1/lot liquidation fee (0.05·3.90 = $0.195 < $1) and a + * partial close could never help. We therefore stage the book at a $40 mark and + * crash to a $30 mark — the same shape used by the unit fixtures — so the + * per-lot MM stress freed by a close (0.05·$30 = $1.50) exceeds the $1 fee. + * + * Sizing (PME shocks 10% IM / 5% MM, $1 flat liquidation fee, entry = $40 mark): + * - unrealized loss / lot after crash = (40 − 30) = $10 + * - MM stress / lot = 0.05·30 = $1.50 ; IM stress / lot = 0.10·30 = $3.00 + * - MM_req = 12·(1.50 + 10) = $138 > $136 deposit ⇒ underwater by ~$2 + * - IM_req = 12·(3.00 + 10) = $156 + * - each closed lot nets +$0.50 to MM surplus ($1.50 stress − $1 fee) and + * +$2.00 to IM surplus ($3.00 stress − $1 fee), so closing ~10 lots lands + * the account on the IM boundary with 2 lots still open — a genuine partial. + * Entry IM (at the $40 mark, no PnL) = 12·0.10·40 = $48, well under the $136 + * deposit, so Alice can open pre-crash (taker fee zeroed — see below). */ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + // Stage the entry mark at $40 (oracle answer $4.00 × 10). Larger than the + // default $4.21 so the moderate-crash stress clears the flat liquidation fee. + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("136", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const aliceFuturesQty = 12; - // Zero the futures taker fee for this fixture only. Opening 12 lots costs - // an entry IM of 84·(0.1·$4.21) = $35.36, which fits the $40 deposit — but - // the default $1/lot taker fee ($12) would drop the post-match balance to - // $28 < IM and revert `InsufficientMarginBalance`. Zeroing it keeps the - // [MM, IM] band math clean; the $1/lot *liquidation* fee still applies to - // the sweep (so the solver's fee-aware sizing is still exercised). + // Zero the futures taker fee for this fixture only so the entry IM ($48) + // isn't inflated by the $1/lot open cost; the $1/lot *liquidation* fee still + // applies to the sweep (so the solver's fee-aware sizing is exercised). await setFuturesTakerFee(base, 0n); await base.deposit(base.accounts.alice.account.address, aliceDeposit); @@ -535,7 +570,7 @@ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -544,38 +579,39 @@ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { ...base, aliceDeposit, aliceFuturesQty, - // Moderate crash: 4.21 → 3.90. Deep enough to break MM, shallow enough + // Moderate crash: $40 → $30 mark. Deep enough to break MM, shallow enough // that a subset of lots restores the IM buffer. - makeLiquidatable: () => - base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } /** * Alice holds 6 long futures lots on EACH of two delivery dates (12 total), - * then takes the same moderate crash (4.21 → 3.90) as - * `futuresPartialCrashFixtureBuilder`. Because the on-chain futures risk model - * weights every lot by the single global `deliveryDurationDays` (7) regardless - * of which date it delivers on, the aggregate MM/IM and unrealized loss are - * identical to the single-expiry 12-lot fixture — so the same $40 deposit - * breaks MM and a worst-first subset restores the IM buffer. The distinction - * under test: the keeper's ONE `liquidatePositions` sweep must close lots from - * BOTH expirations (balanced), not empty the first book before touching the - * second. + * then takes the same moderate crash ($40 → $30 mark) as + * `futuresPartialCrashFixtureBuilder`. In the duration-free model each lot + * carries the same per-day risk weight (multiplier 1) regardless of which date + * it expires on, so the aggregate MM/IM and unrealized loss are identical to the + * single-expiry 12-lot fixture — the same $136 deposit breaks MM and a + * worst-first subset restores the IM buffer. The distinction under test: the + * keeper's ONE `liquidatePositions` sweep must close lots from BOTH expirations + * (balanced), not empty the first book before touching the second. */ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + // Stage the entry mark at $40 (see `futuresPartialCrashFixtureBuilder`). + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("136", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const perExpiryQty = 6; const firstDeliveryAt = base.config.futuresFirstDeliveryDate; - const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_DELIVERY_INTERVAL_DAYS + const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_EXPIRATION_INTERVAL_DAYS // Zero the taker fee (see `futuresPartialCrashFixtureBuilder`) so the 12-lot - // entry IM (~$35.36) fits the $40 deposit; the liquidation-fee payout is - // already disabled contract-side. + // entry IM ($48) fits the $136 deposit; the liquidation fee still applies. await setFuturesTakerFee(base, 0n); await base.deposit(base.accounts.alice.account.address, aliceDeposit); @@ -585,7 +621,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt, quantity: perExpiryQty, }); @@ -596,8 +632,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, perExpiryQty, - makeLiquidatable: () => - base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } @@ -625,7 +660,7 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -642,20 +677,21 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { /** * Cross-venue *partial* crash — the reduce-to-IM-buffer path spanning both * venues. Alice holds a dominant 40-qty perps long plus a small 1-lot futures - * long. A moderate crash (4.21 → 3.00) puts the *combined* portfolio below MM, - * but the account is recoverable by a partial close. Because both legs are long - * at the same entry, the portfolio behaves like one net-long book of size - * `perpQty + deliveryDays·futuresLots` (= 40 + 7 = 47 delta units) for margin - * purposes. + * long. A moderate crash (4.21 → 3.00 mark) puts the *combined* portfolio below + * MM, but the account is recoverable by a partial close. In the duration-free + * model each futures lot is ±1 delta, so the portfolio behaves like one net-long + * book of size `perpQty + futuresLots` (= 40 + 1 = 41 delta units) for margin. * - * Sizing (PME 10% IM / 5% MM, deliveryDays = 7, fee payout disabled): - * - mmReq(3.00) = 47 · (4.21 − 3.00·0.95) = 47 · 1.36 = $63.92 - * - imReq(3.00) = 47 · (4.21 − 3.00·0.90) = 47 · 1.51 = $70.97 - * - $61 deposit < $63.92 ⇒ underwater by ~$2.92 - * - each closed delta unit lifts mmSurplus by mmShock·P = $0.15, imSurplus by - * $0.30, so the deepest in-band close is δ ≈ (70.97−61)/0.30 ≈ 33.2 delta - * units — a PARTIAL perps close (≈6.8 units of the 40 stay open), suppliable - * by the perps leg alone so the futures leg is never touched. + * Sizing (PME 10% IM / 5% MM, entry = $4.21 mark, futures taker fee disabled): + * - mmReq(3.00) = 41·0.05·3.00 + 40·(4.21−3.00) + 1·(4.21−3.00) + * = 6.15 + 48.40 + 1.21 = $55.76 + * - imReq(3.00) = 41·0.10·3.00 + 49.61 = 12.30 + 49.61 = $61.91 + * - $53 deposit < $55.76 ⇒ underwater by ~$2.76 + * - closing a perps unit frees imShock·P = $0.30 of IM surplus (its realized + * loss cancels the freed unrealized loss), so the deepest in-band close is + * δ ≈ (61.91 − 53 + $1 flat fee)/0.30 ≈ 33 units — a PARTIAL perps close + * (~7 of the 40 stay open), suppliable by the perps leg alone so the futures + * leg is never touched. * * The flat $1/lot futures taker fee is zeroed for this fixture (as in * `futuresPartialCrashFixtureBuilder`) so it doesn't eat into the narrow @@ -672,7 +708,7 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("61", base.config.tokenDecimals); + const aliceDeposit = parseUnits("53", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); const aliceFuturesQty = 1; @@ -685,13 +721,13 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -702,7 +738,7 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { alicePerpsQty, aliceFuturesQty, makeLiquidatable: () => - base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + base.crashOracles(parseUnits("3.00", base.config.tokenDecimals)), }; }; } @@ -715,18 +751,24 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { * in the partial regime (distinct from both the single-venue-suffices partial * test and the 99.8% deep-crash test that wipes everything into bad debt). * - * Alice holds a dominant 6-lot futures long + a 25-qty perps long (delta units: - * futures 6·7 = 42, perps 25; S = 67). Moderate crash 4.21 → 3.00: - * - mmReq(3.00) = 67 · 1.36 = $91.12 ; imReq = 67 · 1.51 = $101.17 - * - $83 deposit ⇒ underwater by ~$8.12 (substantial) - * - futures is worst by loss ($50.82 > $30.25), so it's reduced first — but - * even fully closing all 6 futures lots only lifts mmSurplus by 6·0.15·7 = - * $6.30, short of the $8.12 deficit, so the account is STILL under MM (the - * futures leg simply doesn't have the lots to close the gap alone). + * Staged at a $40 mark (crash to $30) so the per-lot stress clears the flat + * liquidation fee — the duration-free equivalent of the old $4.21-scale sizing. + * Alice holds a dominant 12-lot futures long + an 11-qty perps long (delta + * units: futures 12·1 = 12, perps 11; S = 23). Futures is made the worst leg by + * lot count (each lot now ±1 delta, so its loss must out-number the perps qty). + * Moderate crash 40 → 30: + * - mmReq(30) = 23·0.05·30 + 11·(40−30) + 12·(40−30) + * = 34.50 + 110 + 120 = $264.50 + * - imReq(30) = 23·0.10·30 + 230 = 69 + 230 = $299 + * - $250 deposit ⇒ underwater by ~$14.50 (substantial) + * - futures is worst by loss ($120 > $110), so it's reduced first — but fully + * closing all 12 lots realizes $120 of loss + $12 liquidation fee, dropping + * the balance to $118 against a residual perps mmReq of $126.50, so the + * account is STILL under MM (the futures leg can't close the gap alone). * - the planner then takes a SECOND iteration and reduces the perps leg. Perps - * closes by a *continuous* quantity, so the solver lands the account - * precisely on the IM boundary — a robust in-band result (residual perps - * ~6.4 qty stays open), unlike the discrete futures-lot granularity. + * closes by a *continuous* quantity: it needs δ ≈ (143 − 118 + $1 fee)/3.00 + * ≈ 9 of the 11 qty, landing precisely on the IM boundary (residual perps + * ~2 qty stays open), unlike the discrete futures-lot granularity. * * Net effect the test asserts: BOTH venues carry liquidation activity in the * one sweep (futures fully closed, perps partially closed), the account lands in @@ -737,10 +779,14 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("83", base.config.tokenDecimals); + // Stage entry at a $40 mark (see `futuresPartialCrashFixtureBuilder`). + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("250", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); - const alicePerpsQty = parseUnits("25", base.config.quantityDecimals); - const aliceFuturesQty = 6; + const alicePerpsQty = parseUnits("11", base.config.quantityDecimals); + const aliceFuturesQty = 12; await setFuturesTakerFee(base, 0n); @@ -750,13 +796,13 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -766,8 +812,7 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, alicePerpsQty, aliceFuturesQty, - makeLiquidatable: () => - base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } @@ -780,10 +825,10 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { * Two parameterised variants are exposed via dedicated builders: * * - `crossVenuePerpsDominantFixtureBuilder` — perps `unrealizedLoss` - * dominates futures (ratio ≈ 14:1). The planner should liquidate + * dominates futures (ratio ≈ 100:1). The planner should liquidate * perps first, then futures. * - `crossVenueFuturesDominantFixtureBuilder` — futures dominates perps - * (ratio ≈ 1:140). The planner should liquidate futures first. + * (ratio ≈ 12:1). The planner should liquidate futures first. * * Together they prove the planner ranks by *loss size*, not venue order. */ @@ -799,13 +844,13 @@ function crossVenueFixtureBody( await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -822,9 +867,9 @@ function crossVenueFixtureBody( } /** - * Perps-dominant: alice has a 100-qty perps long ($420 unrealized loss - * after the crash) and a 1-unit futures long ($29.40 loss). The planner - * must liquidate perps first by `unrealizedLoss` ranking. + * Perps-dominant: alice has a 100-qty perps long ($420 unrealized loss after the + * crash to a $0.01 mark) and a 1-unit futures long ($4.20 loss, duration-free). + * The planner must liquidate perps first by `unrealizedLoss` ranking. */ export function crossVenuePerpsDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { @@ -857,7 +902,10 @@ export function crossVenuePerpsDominantFixtureBuilder(rpcUrl: string) { export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("250", base.config.tokenDecimals); + // Duration-free rescale: the 6-lot futures leg contributes ~$25 of loss + // (was ~$176 with the ×7 window), so the deposit drops to keep the combined + // book underwater after the deep crash and fully wiped across both venues. + const aliceDeposit = parseUnits("150", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); const aliceFuturesQty = 6; @@ -868,13 +916,13 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -910,9 +958,9 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { } /** - * Futures-dominant: alice has a 1-qty perps long ($4.20 unrealized loss) - * and a 12-unit futures long ($352.80 loss over the 7-day delivery - * window). The planner must liquidate futures first. + * Futures-dominant: alice has a 1-qty perps long ($4.20 unrealized loss) and a + * 12-unit futures long ($50.40 loss, duration-free: 12 · ($4.21 − $0.01 mark)). + * The planner must liquidate futures first. * * The futures qty is capped at 12 because `createOrder` loops once per * contract in the matching engine; larger values blow past Hardhat's @@ -922,7 +970,7 @@ export function crossVenueFuturesDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); return crossVenueFixtureBody(base, { - aliceDeposit: parseUnits("300", base.config.tokenDecimals), + aliceDeposit: parseUnits("40", base.config.tokenDecimals), bobDeposit: parseUnits("5000", base.config.tokenDecimals), alicePerpsQty: parseUnits("1", base.config.quantityDecimals), aliceFuturesQty: 12, diff --git a/keeper/tests/oracle/priceFeed.test.ts b/keeper/tests/oracle/priceFeed.test.ts index 7c8f9a5..dcf530c 100644 --- a/keeper/tests/oracle/priceFeed.test.ts +++ b/keeper/tests/oracle/priceFeed.test.ts @@ -8,6 +8,7 @@ import type { Config } from "../../src/config.ts"; const HASHPRICE = "0x000000000000000000000000000000000000aa01" as Address; const BTC_FEED = "0x000000000000000000000000000000000000aa02" as Address; +const PERPS = "0x000000000000000000000000000000000000aa03" as Address; function makeConfig(): Config { return { @@ -16,6 +17,7 @@ function makeConfig(): Config { btcUsdcFeedAddress: BTC_FEED, priceMoveTriggerBps: 0, }, + perps: { address: PERPS }, } as Config; } @@ -44,6 +46,12 @@ function makeChainStub(initialAnswer: bigint, decimals: number): ChainStub { publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return decimals; + // Contract-size rebase reads. Returning equal values gives a 1× + // passthrough so these tests assert the pure decimals rebase without a + // contract-size multiplier (the x10 factor is exercised by the venue / + // integration paths that use the real 1e15 / 100e12 constants). + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { reads++; return [1n, currentAnswer, 1_000n, 1_000n, 1n] as const; diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 1fea37d..4ae47e7 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -87,6 +87,11 @@ function buildHarness({ publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; + // Contract-size rebase reads (PriceFeed.start). Equal values → 1× + // passthrough, so the streamed price stays $100 and matches the + // `computePortfolioMM` price the harness derives from the same answer. + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } @@ -124,8 +129,6 @@ function buildHarness({ return 0n; case "getPositionIds": return []; - case "deliveryDurationDays": - return 30; case "computePortfolioIM": return balance / 2n; case "computePortfolioMM": { diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 894466e..918ff7b 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -64,6 +64,11 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; + // Contract-size rebase reads (PriceFeed.start). Equal values → 1× + // passthrough, so the streamed price stays $100 and matches the IM/MM + // the harness derives from the same answer. + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } @@ -99,8 +104,6 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return 0n; case "getPositionIds": return []; - case "deliveryDurationDays": - return 30; case "computePortfolioIM": return imAtPriceTokens(currentPrice); case "computePortfolioMM": diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts index e1e56ef..5c28461 100644 --- a/keeper/tests/predict/mm.test.ts +++ b/keeper/tests/predict/mm.test.ts @@ -33,7 +33,7 @@ function emptySnapshot(overrides: Partial = {}): AccountSnapsho user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } @@ -58,16 +58,15 @@ describe("predict/mm: netDeltaWad", () => { assert.equal(netDeltaWad(snap, PARAMS), -2_000_000_000_000_000_000n); }); - it("adds futures buyer delta scaled by deliveryDays", () => { - // Buyer of 1 contract over 30 days → +30 * 1e18 WAD delta. + it("adds futures buyer delta (±1 per contract, no duration factor)", () => { + // Buyer of 1 contract → +1 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(netDeltaWad(snap, PARAMS), 30n * 10n ** 18n); + assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); }); it("subtracts futures seller delta", () => { @@ -75,10 +74,9 @@ describe("predict/mm: netDeltaWad", () => { futures: { positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(netDeltaWad(snap, PARAMS), -30n * 10n ** 18n); + assert.equal(netDeltaWad(snap, PARAMS), -1n * 10n ** 18n); }); it("sums perps + futures legs into one signed delta", () => { @@ -90,10 +88,9 @@ describe("predict/mm: netDeltaWad", () => { { id: "0xbb", isBuyer: false, entryPricePerDay: 60n, deliveryAt: 1_756_416_000n }, ], orderMargin: 0n, - deliveryDays: 30n, }, }); - // Perp +1e18; futures +30e18 - 30e18 = 0 → net = +1e18. + // Perp +1e18; futures +1e18 - 1e18 = 0 → net = +1e18. assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); }); }); @@ -168,16 +165,15 @@ describe("predict/mm: futuresUnrealizedLoss", () => { assert.equal(futuresUnrealizedLoss(emptySnapshot(), 100_000_000n), 0n); }); - it("buyer loses when P drops below entry; loss scales by deliveryDays", () => { + it("buyer loses when P drops below entry (no duration factor)", () => { const snap = emptySnapshot({ futures: { positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10 * 30 = -300. loss = 300. - assert.equal(futuresUnrealizedLoss(snap, 40n), 300n); + // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10. loss = 10. + assert.equal(futuresUnrealizedLoss(snap, 40n), 10n); }); it("seller loses when P rises above entry", () => { @@ -185,28 +181,26 @@ describe("predict/mm: futuresUnrealizedLoss", () => { futures: { positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(futuresUnrealizedLoss(snap, 60n), 300n); + assert.equal(futuresUnrealizedLoss(snap, 60n), 10n); }); it("sums losses across multiple positions; profitable legs do not net out", () => { const snap = emptySnapshot({ futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 → loses 300 - { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 → loses 300 + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 → loses 10 + { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 → loses 10 ], orderMargin: 0n, - deliveryDays: 30n, }, }); // Loss is sum of *losing* legs only (consistent with `max(0, -pnl)` per leg // mirroring the on-chain `getFuturesUnrealizedPnl` aggregation, which // would be 0 net but PME treats them piecewise via stress + per-leg PnL). // Here both happen to be losing — buyer down, seller up. - assert.equal(futuresUnrealizedLoss(snap, 40n), 600n); + assert.equal(futuresUnrealizedLoss(snap, 40n), 20n); }); }); @@ -215,7 +209,7 @@ describe("predict/mm: mmRequired / mmSurplus / imRequired / imSurplus", () => { const snap = emptySnapshot({ balance: 1_000n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 100n, fundingOwed: 50n }, - futures: { positions: [], orderMargin: 25n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 25n }, }); // No delta → no stress, no PnL. orderMargin + funding = 175. assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 175n); diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index dc32b26..7af4f55 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -44,7 +44,6 @@ function makeChain(scripted: { mmShock?: bigint; tokenDecimals?: number; perpQtyDecimals?: number; - deliveryDays?: number; }): Chain { return { publicClient: { @@ -70,8 +69,6 @@ function makeChain(scripted: { return scripted.futuresOrderMargin ?? 0n; case "getPositionIds": return scripted.futuresPositionIds ?? []; - case "deliveryDurationDays": - return scripted.deliveryDays ?? 30; case "getPositionById": { const id = c.args?.[0] as string; const pos = scripted.futuresPositions?.[id]; @@ -114,7 +111,6 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.perp.netQty, 0n); assert.equal(snap.perp.fundingOwed, 0n); assert.equal(snap.futures.positions.length, 0); - assert.equal(snap.futures.deliveryDays, 30n); }); it("clamps pending funding to >= 0 (PME treats credits as not-owed)", async () => { diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts index 099f788..72e6dba 100644 --- a/keeper/tests/predict/solve.test.ts +++ b/keeper/tests/predict/solve.test.ts @@ -20,7 +20,7 @@ function emptySnapshot(overrides: Partial = {}): AccountSnapsho user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } @@ -138,19 +138,20 @@ describe("predict/solve: solveLiquidationThresholds", () => { }); it("handles a futures buyer position the same way as a long perp", () => { - // Buyer of 1 contract over 30 days @ $50/day, balance $200. - // Below entry: stress + (entry - P) * 30 days - // delta = 30 * WAD; stress = |delta| * shock * P / WAD² → 30 * 0.05 * P / 1 = 1.5 P (per token decimals). - // Hmm — let me just verify via mmSurplus at the returned threshold. + // Buyer of 1 contract @ $50/day (delta = 1 * WAD; no duration factor), + // collateral $30. Below entry: mmRequired(P) = stress(P) + (entry - P) + // = 0.05 P + (50 - P) = 50 - 0.95 P (token decimals). + // surplus(P) = 30 - (50 - 0.95 P) = -20 + 0.95 P → crosses 0 ≈ $21.05. const snap = emptySnapshot({ - balance: 200n, + balance: 30_000_000n, futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [ + { id: "0xaa", isBuyer: true, entryPricePerDay: 50_000_000n, deliveryAt: 1_756_416_000n }, + ], orderMargin: 0n, - deliveryDays: 30n, }, }); - const out = solveLiquidationThresholds(snap, PARAMS, 50n); + const out = solveLiquidationThresholds(snap, PARAMS, 50_000_000n); assert.notEqual(out.liqDown, undefined); if (out.liqDown !== undefined) { assertCrossing(snap, PARAMS, out.liqDown, "down"); diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts index b239995..7eb5997 100644 --- a/keeper/tests/predict/solveTarget.test.ts +++ b/keeper/tests/predict/solveTarget.test.ts @@ -28,6 +28,15 @@ const FEE = 1_000_000n; // $1 flat liquidation fee const EXPIRY_A = 1_756_416_000n; const EXPIRY_B = 1_759_008_000n; +// Duration-free contract sizing. Each lot is a single contract that settles +// `entryPricePerDay` of notional (no `× deliveryDays` factor). For a close to +// improve MM surplus the stress it frees (`spotShock × P`) must exceed the flat +// fee, so the moderate-crash price sits well above `20 × FEE` — hence the +// $40/$30 magnitudes below rather than the old sub-dollar per-day prices. +const ENTRY_PER_DAY = 40_000_000n; // $40/day entry +const P_MODERATE = 30_000_000n; // $30/day: underwater but recoverable via a subset +const BALANCE = 136_000_000n; // collateral: underwater at P_MODERATE, healable by a partial close + function futuresLot(id: Hex, entryPricePerDay: bigint, isBuyer = true, deliveryAt = EXPIRY_A) { return { id, isBuyer, entryPricePerDay, deliveryAt }; } @@ -37,33 +46,33 @@ function futuresSnapshot(overrides: Partial = {}): AccountSnaps user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 7n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } -/** 12 identical $4.21/day long lots — the integration `futuresPartialCrash` shape. */ +/** 12 identical $40/day long lots — the integration `futuresPartialCrash` shape. */ function twelveLongLots(): AccountSnapshot { const positions = []; for (let i = 0; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY)); } - return futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); + return futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); } describe("predict/solve: solveFuturesLotsToTarget", () => { it("returns an empty set when the account is already healthy", () => { const snap = futuresSnapshot({ balance: 1_000_000_000n, - futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, 4_210_000n)], orderMargin: 0n, deliveryDays: 7n }, + futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, ENTRY_PER_DAY)], orderMargin: 0n }, }); - const ids = solveFuturesLotsToTarget(snap, PARAMS, 4_000_000n, FEE); + const ids = solveFuturesLotsToTarget(snap, PARAMS, P_MODERATE, FEE); assert.equal(ids.length, 0); }); it("closes a strict worst-first subset that lands inside the [MM, IM] band", () => { const snap = twelveLongLots(); - const P = 3_900_000n; // moderate crash → underwater but recoverable + const P = P_MODERATE; // moderate crash → underwater but recoverable // Precondition: the account really is underwater at P. assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); @@ -80,7 +89,7 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { it("is the DEEPEST in-band subset — closing one more worst-first lot breaches IM", () => { const snap = twelveLongLots(); - const P = 3_900_000n; + const P = P_MODERATE; const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); // There is still a lot to add and doing so would push balance over IM. @@ -104,7 +113,7 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { it("degenerate IM == MM: targets minimal healthy (no upper IM bound)", () => { const snap = twelveLongLots(); - const P = 3_900_000n; + const P = P_MODERATE; const degenerate: MMParams = { ...PARAMS, imSpotShock: PARAMS.mmSpotShock }; const ids = solveFuturesLotsToTarget(snap, degenerate, P, FEE); assert.ok(ids.length > 0 && ids.length <= snap.futures.positions.length); @@ -120,13 +129,13 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { // spread the closures across both books. const positions: AccountSnapshot["futures"]["positions"] = []; for (let i = 0; i < 6; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); } for (let i = 6; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); } - const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); - const P = 3_900_000n; + const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + const P = P_MODERATE; assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); @@ -155,13 +164,13 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { // lots as B — rather than emptying the smaller book first. const positions: AccountSnapshot["futures"]["positions"] = []; for (let i = 0; i < 8; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); } for (let i = 8; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); } - const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); - const P = 3_900_000n; + const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + const P = P_MODERATE; const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); const byExpiry = (deliveryAt: bigint) => @@ -181,7 +190,7 @@ function perpSnapshot(netQty: bigint, entryPrice: bigint, balance: bigint): Acco user: USER, balance, perp: { netQty, entryPrice, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, }; } diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts index f1d2862..71d0b36 100644 --- a/keeper/tests/venues/futures.test.ts +++ b/keeper/tests/venues/futures.test.ts @@ -47,12 +47,10 @@ const silentLogger = { } as unknown as ConstructorParameters[2]; const DELIVERY_AT = 1_756_416_000n; // 2025-08-28T18:40:00Z (slice(0,10) → "2025-08-28") -const DELIVERY_DURATION_DAYS = 7n; -/** Reusable stub: deliveryDurationDays + market price + (positionIds | orderIds) reads. */ -function makeReadHandler(deliveryDurationDays: bigint, marketPrice: bigint, listResult: readonly Hex[]) { +/** Reusable stub: market price + (positionIds | orderIds) reads. */ +function makeReadHandler(marketPrice: bigint, listResult: readonly Hex[]) { return (call: ReadCall): unknown => { - if (call.functionName === "deliveryDurationDays") return Number(deliveryDurationDays); if (call.functionName === "getMarketPrice") return marketPrice; if (call.functionName === "getOrderIds" || call.functionName === "getPositionIds") return listResult; throw new Error(`unexpected readContract call: ${call.functionName}`); @@ -71,7 +69,7 @@ describe("futures venue: readOpenOrders", () => { it("returns empty when getOrderIds is empty (no extra multicall)", async () => { let multicallCount = 0; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + readContract: makeReadHandler(100n, []), multicall: () => { multicallCount++; return []; @@ -89,7 +87,7 @@ describe("futures venue: readOpenOrders", () => { "0x000000000000000000000000000000000000000000000000000000000000000b", ]; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, orderIds), + readContract: makeReadHandler(100n, orderIds), multicall: (calls) => { // One getOrderById per order id, in order. assert.equal(calls.length, 2); @@ -112,7 +110,7 @@ describe("futures venue: readOpenOrders", () => { describe("futures venue: readPositions", () => { it("returns empty when getPositionIds is empty", async () => { const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + readContract: makeReadHandler(100n, []), multicall: () => [], }); const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); @@ -124,9 +122,9 @@ describe("futures venue: readPositions", () => { const positionIds: Hex[] = ["0x" + "11".repeat(32) as Hex]; const buyPx = 100n; const sellPx = 100n; - const marketPrice = 70n; // long → loses (100-70)*7days = 210 per contract + const marketPrice = 70n; // long → loses (100-70) = 30 per contract (no duration factor) const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + readContract: makeReadHandler(marketPrice, positionIds), multicall: (calls) => { assert.equal(calls.length, 1); assert.equal(calls[0]?.functionName, "getPositionById"); @@ -144,8 +142,8 @@ describe("futures venue: readPositions", () => { const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(BUYER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, (buyPx - marketPrice) * DELIVERY_DURATION_DAYS); - assert.equal(pos.notional, buyPx * DELIVERY_DURATION_DAYS); + assert.equal(pos.unrealizedLoss, buyPx - marketPrice); + assert.equal(pos.notional, buyPx); assert.equal(pos.marketId, deliveryAtMarketId(DELIVERY_AT)); }); @@ -153,9 +151,9 @@ describe("futures venue: readPositions", () => { const positionIds: Hex[] = ["0x" + "22".repeat(32) as Hex]; const sellPx = 100n; const buyPx = 100n; - const marketPrice = 130n; // short → loses (130-100)*7days = 210 per contract + const marketPrice = 130n; // short → loses (130-100) = 30 per contract (no duration factor) const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + readContract: makeReadHandler(marketPrice, positionIds), multicall: () => [ { seller: SELLER, @@ -169,14 +167,14 @@ describe("futures venue: readPositions", () => { const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(SELLER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, (marketPrice - sellPx) * DELIVERY_DURATION_DAYS); - assert.equal(pos.notional, sellPx * DELIVERY_DURATION_DAYS); + assert.equal(pos.unrealizedLoss, marketPrice - sellPx); + assert.equal(pos.notional, sellPx); }); it("reports zero loss when the user is in profit", async () => { const positionIds: Hex[] = ["0x" + "33".repeat(32) as Hex]; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 150n, positionIds), + readContract: makeReadHandler(150n, positionIds), multicall: () => [ { seller: SELLER, @@ -192,25 +190,4 @@ describe("futures venue: readPositions", () => { assert.ok(pos); assert.equal(pos.unrealizedLoss, 0n, "buyer with market > entry is in profit"); }); - - it("caches deliveryDurationDays across calls (read once)", async () => { - let durationReads = 0; - const chain = makeChainStub({ - readContract: (call) => { - if (call.functionName === "deliveryDurationDays") { - durationReads++; - return Number(DELIVERY_DURATION_DAYS); - } - if (call.functionName === "getMarketPrice") return 100n; - if (call.functionName === "getPositionIds") return []; - throw new Error(`unexpected ${call.functionName}`); - }, - multicall: () => [], - }); - const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); - await venue.readPositions(BUYER); - await venue.readPositions(BUYER); - await venue.readPositions(BUYER); - assert.equal(durationReads, 1, "deliveryDurationDays read only once"); - }); }); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index b50c9d4..ce55a4b 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -77,7 +77,6 @@ function makeChainStub(opts: { 0n, // getPendingFunding 0n, // getFuturesOrderMargin opts.futuresPositionIds, - 7, // deliveryDurationDays ]; } // readAccountSnapshot per-position hydration @@ -104,18 +103,18 @@ function makeChainStub(opts: { describe("futures venue: reduceToTarget", () => { it("sizes a strict worst-first lot subset and submits one liquidatePositions batch", async () => { - // 12 long lots @ $4.21/day, $40 deposit, crash to $3.90 — underwater but - // recoverable (mirrors the solver's in-band fixture). + // 12 long lots @ $40/day, $136 deposit, crash to $30 — underwater but + // recoverable (mirrors the solver's in-band fixture; no duration factor). const ids: Hex[] = []; for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); let simulated: ReadCall | undefined; const chain = makeChainStub({ - balance: 40_000_000n, - marketPrice: 3_900_000n, + balance: 136_000_000n, + marketPrice: 30_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { simulated = call; }, @@ -133,7 +132,7 @@ describe("futures venue: reduceToTarget", () => { }); it("caps the batch to maxLotsPerLiquidationTx (gas-bounded chunking)", async () => { - // 12 long lots @ $4.21/day, $40 deposit, crash to $1.00 — a deep crash the + // 12 long lots @ $40/day, $136 deposit, crash to $1.00 — a deep crash the // solver resolves to a FULL close (all 12 ids). With a cap below 12, // `reduceToTarget` must send only the worst-first prefix and report // `positionsClosed` == cap; the planner loop drains the rest next iteration. @@ -143,12 +142,12 @@ describe("futures venue: reduceToTarget", () => { // Uncapped target first, so the assertion is robust to the solver's sizing. let full: Hex[] = []; const chainFull = makeChainStub({ - balance: 40_000_000n, + balance: 136_000_000n, marketPrice: 1_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { full = (call.args as [Address, Hex[]])[1]; }, @@ -159,12 +158,12 @@ describe("futures venue: reduceToTarget", () => { const cap = full.length - 1; let chunk: Hex[] = []; const chainCap = makeChainStub({ - balance: 40_000_000n, + balance: 136_000_000n, marketPrice: 1_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { chunk = (call.args as [Address, Hex[]])[1]; }, @@ -183,11 +182,11 @@ describe("futures venue: reduceToTarget", () => { let simulateCalled = false; const chain = makeChainStub({ balance: 1_000_000_000n, // fully collateralised - marketPrice: 3_900_000n, + marketPrice: 30_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: () => { simulateCalled = true; }, diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 5bc3795..52d0f96 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -2,9 +2,11 @@ import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { + BatchableCollateralAccount, CollateralAccount, CollateralSnapshot, InstrumentAdapter, + MarginReadPlan, VenueAdapter, VenueEvents, WalletContext, @@ -20,6 +22,17 @@ import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; import { FuturesVenueEvents } from "./events.ts"; +/** + * How the venue picks which delivery dates to quote out of the rolling window + * returned by `getDeliveryDates()` (ordered nearest-first). + * + * - `nearest`: the first `count` dates (count=1 reproduces the legacy MVP). + * - `indices`: explicit relative offsets into the window (0 = nearest). + */ +export type FuturesMarketSelection = + | { mode: "nearest"; count: number } + | { mode: "indices"; indices: number[] }; + export interface FuturesVenueOptions { network: NetworkClients; wallet: WalletContext; @@ -29,9 +42,21 @@ export interface FuturesVenueOptions { readBatchSize: number; /** Max closeOrder calls per cancellation batch. Default 20. */ writeBatchSize: number; + /** Which delivery dates to quote. Defaults to `{ mode: "nearest", count: 1 }`. */ + marketSelection?: FuturesMarketSelection; logger: pino.Logger; } +/** Outcome of a `resolveMarkets()` roll check. */ +export interface FuturesMarketSet { + /** Instruments the venue currently wants quoted, nearest-first. */ + active: FuturesInstrumentAdapter[]; + /** Instruments newly added since the previous resolve (need bootstrap). */ + added: FuturesInstrumentAdapter[]; + /** Instruments dropped since the previous resolve (matured / rolled off). */ + dropped: FuturesInstrumentAdapter[]; +} + /** * Futures venue: Futures contract, the shared CollateralVault, the * PortfolioMarginEngine. @@ -60,12 +85,15 @@ export class FuturesVenueAdapter implements VenueAdapter { private readonly multicall3Address: `0x${string}`; readonly readBatchSize: number; readonly writeBatchSize: number; - private instrumentSingleton: FuturesInstrumentAdapter | null = null; + private readonly marketSelection: FuturesMarketSelection; + /** deliveryDate → instrument, memoized so each expiry has one adapter. */ + private readonly instruments = new Map(); + /** deliveryDates currently selected (as strings), from the last resolve. */ + private activeKeys: string[] = []; private vaultAddressCache: `0x${string}` | null = null; private engineAddressCache: `0x${string}` | null = null; private collateralTokenCache: `0x${string}` | null = null; - private deliveryDurationDaysCache: bigint | null = null; private marginPercentCache: bigint | null = null; private readonly rawOracle: RawOracleReader; @@ -85,6 +113,7 @@ export class FuturesVenueAdapter implements VenueAdapter { this.multicall3Address = mc3; this.readBatchSize = opts.readBatchSize; this.writeBatchSize = opts.writeBatchSize; + this.marketSelection = opts.marketSelection ?? { mode: "nearest", count: 1 }; this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); @@ -96,7 +125,7 @@ export class FuturesVenueAdapter implements VenueAdapter { publicClient: this.publicClient, label: "futures", resolve: async () => { - const [oracle, divisor] = await this.publicClient.multicall({ + const [oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay] = await this.publicClient.multicall({ allowFailure: false, contracts: [ { @@ -109,26 +138,113 @@ export class FuturesVenueAdapter implements VenueAdapter { abi: FuturesAbi, functionName: "hashpriceScalingDivisor", }, + { + address: this.address, + abi: FuturesAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }, + { + address: this.address, + abi: FuturesAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }, ], }); - return { oracle, divisor }; + return { oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay }; }, }); } + /** Nearest-expiry instrument. Back-compat / single-market entrypoint. */ async getInstrument(): Promise { - if (!this.instrumentSingleton) { - this.instrumentSingleton = new FuturesInstrumentAdapter( - this, - this.logger, + const dates = await this.readDeliveryDates(); + if (dates.length === 0) throw new Error("futures contract returned no delivery dates"); + return this.instrumentFor(dates[0]); + } + + /** All currently-selected expiries, nearest-first. */ + async listInstruments(): Promise { + const { active } = await this.resolveMarkets(); + return active; + } + + /** + * Re-read the rolling delivery-date window, apply the configured selection, + * and diff against the previously-active set. Instruments are memoized per + * expiry, so `added`/`dropped` let the runner bootstrap new markets and tear + * down matured ones without disturbing the survivors. + */ + async resolveMarkets(): Promise { + const dates = await this.readDeliveryDates(); + const selected = this.selectDates(dates); + const selectedKeys = selected.map((d) => d.toString()); + + const prev = new Set(this.activeKeys); + const next = new Set(selectedKeys); + + const added: FuturesInstrumentAdapter[] = []; + for (const d of selected) { + if (!prev.has(d.toString())) added.push(this.instrumentFor(d)); + } + const dropped: FuturesInstrumentAdapter[] = []; + for (const key of this.activeKeys) { + if (!next.has(key)) { + const inst = this.instruments.get(key); + if (inst) dropped.push(inst); + this.instruments.delete(key); + } + } + + this.activeKeys = selectedKeys; + const active = selected.map((d) => this.instrumentFor(d)); + + if (added.length > 0 || dropped.length > 0) { + this.logger.info( + { + active: active.map((i) => i.deliveryDate.toString()), + added: added.map((i) => i.deliveryDate.toString()), + dropped: dropped.map((i) => i.deliveryDate.toString()), + }, + "futures markets resolved", ); } - return this.instrumentSingleton; + return { active, added, dropped }; + } + + private instrumentFor(deliveryDate: bigint): FuturesInstrumentAdapter { + const key = deliveryDate.toString(); + let inst = this.instruments.get(key); + if (!inst) { + inst = new FuturesInstrumentAdapter(this, deliveryDate, this.logger); + this.instruments.set(key, inst); + } + return inst; + } + + private async readDeliveryDates(): Promise { + const dates = await this.publicClient.readContract({ + address: this.address, + abi: FuturesAbi, + functionName: "getDeliveryDates", + }); + return [...dates]; + } + + private selectDates(dates: bigint[]): bigint[] { + if (dates.length === 0) return []; + if (this.marketSelection.mode === "nearest") { + return dates.slice(0, Math.max(0, this.marketSelection.count)); + } + const out: bigint[] = []; + for (const idx of this.marketSelection.indices) { + if (idx >= 0 && idx < dates.length) out.push(dates[idx]); + } + return out; } async multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint } = {}, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, ): Promise<`0x${string}`> { try { return await this.wallet.walletClient.writeContract({ @@ -139,6 +255,7 @@ export class FuturesVenueAdapter implements VenueAdapter { account: this.wallet.account, chain: this.chain, maxFeePerGas: opts.maxFeePerGas, + nonce: opts.nonce, }); } catch (err) { // Attach a Tenderly simulation URL so the failed multicall can be @@ -217,47 +334,23 @@ export class FuturesVenueAdapter implements VenueAdapter { } /** - * Cache delivery-duration-days and marginPercent on the venue. Both are - * static-ish (admin-changeable) so we read them once and reuse for the - * `estimateOrderMargin` formula. + * Cache marginPercent on the venue. It is static-ish (admin-changeable) so we + * read it once and reuse for the `estimateOrderMargin` formula. */ - async getMarginInputs(): Promise<{ - deliveryDurationDays: bigint; - marginPct: bigint; - }> { - if ( - this.deliveryDurationDaysCache !== null && - this.marginPercentCache !== null - ) { - return { - deliveryDurationDays: this.deliveryDurationDaysCache, - marginPct: this.marginPercentCache, - }; + async getMarginInputs(): Promise<{ marginPct: bigint }> { + if (this.marginPercentCache !== null) { + return { marginPct: this.marginPercentCache }; } - const [duration, liqMarginPct] = await this.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: this.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - }, - { - address: this.address, - abi: FuturesAbi, - functionName: "liquidationMarginPercent", - }, - ], + const liqMarginPct = await this.publicClient.readContract({ + address: this.address, + abi: FuturesAbi, + functionName: "liquidationMarginPercent", }); // Note: `getMarginPercent` on chain adds a breach-penalty term we don't // mirror here — we use `liquidationMarginPercent` as a slight over-estimate. // The on-chain check is the real authority; this is just our pre-trade gate. - this.deliveryDurationDaysCache = BigInt(duration); this.marginPercentCache = BigInt(liqMarginPct); - return { - deliveryDurationDays: this.deliveryDurationDaysCache, - marginPct: this.marginPercentCache, - }; + return { marginPct: this.marginPercentCache }; } } @@ -268,88 +361,66 @@ export class FuturesVenueAdapter implements VenueAdapter { * portfolio IM/MM, futures order margin (positive resting margin), futures * unrealized PnL (signed), wallet ERC20 balance, native ETH balance. */ -class FuturesCollateralAccount implements CollateralAccount { +class FuturesCollateralAccount implements BatchableCollateralAccount { private readonly venue: FuturesVenueAdapter; constructor(venue: FuturesVenueAdapter) { this.venue = venue; } - async snapshot(): Promise { + /** + * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads. + * `shared` order matches the perps account so the aggregator can decode one + * shared result slice for every venue: + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + */ + async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; const { vault, engine, token } = await this.venue.resolveAddresses(); const mc3 = this.venue.getMulticall3Address(); - const [ - vaultBalance, - portfolioIM, - portfolioMM, - orderMargin, - unrealizedPnl, - walletTokenBalance, - nativeBalance, - ] = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: vault, - abi: CollateralVaultAbi, - functionName: "balanceOf", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioIM", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioMM", - args: [owner], - }, - { - address: this.venue.address, - abi: FuturesAbi, - functionName: "getFuturesOrderMargin", - args: [owner], - }, - { - address: this.venue.address, - abi: FuturesAbi, - functionName: "getFuturesUnrealizedPnl", - args: [owner], - }, - { - address: token, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }, - { - address: mc3, - abi: Multicall3Abi, - functionName: "getEthBalance", - args: [owner], - }, - ], - }); + const shared = [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ] as MarginReadPlan["shared"]; - return { - vaultBalance, - portfolioIM, - portfolioMM, - venueOrderMargin: orderMargin, - venueUnrealizedPnl: unrealizedPnl, - walletTokenBalance, - nativeBalance, - collateralToken: token, + const venue = [ + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesOrderMargin", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesUnrealizedPnl", args: [owner] }, + ] as MarginReadPlan["venue"]; + + const decode = (results: readonly unknown[]): CollateralSnapshot => { + const r = results as bigint[]; + const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: r[5], + venueUnrealizedPnl: r[6], + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; }; + + return { shared, venue, decode }; + } + + async snapshot(): Promise { + const plan = await this.buildMarginReadPlan(); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [...plan.shared, ...plan.venue], + }); + return plan.decode(results); } async imSpotShock(): Promise { - // Futures uses pricePerDay × deliveryDurationDays × marginPct/100, not a - // spot-shock model. Returns 0 to signal "not applicable" — adapters don't + // Futures uses pricePerDay × marginPct/100 (one unit, no duration multiplier), + // not a spot-shock model. Returns 0 to signal "not applicable" — adapters don't // use this directly; estimateOrderMargin reads from getMarginInputs instead. return 0n; } diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 0caae25..abc9f14 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -2,9 +2,11 @@ import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { + BatchableCollateralAccount, CollateralAccount, CollateralSnapshot, InstrumentAdapter, + MarginReadPlan, VenueAdapter, VenueEvents, WalletContext, @@ -15,6 +17,7 @@ import { CollateralVaultAbi } from "collateral-margin-contracts/abi/CollateralVa import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; +import { QUANTITY_DECIMALS } from "../../core/math.ts"; import { RawOracleReader, chainlinkAggregatorAbi, @@ -106,7 +109,7 @@ export class PerpsVenueAdapter implements VenueAdapter { abi: HashPowerPerpsDEXAbi, functionName: "priceOracle", }); - const [oracleDecimals, tokenDecimals] = + const [oracleDecimals, tokenDecimals, contractSizeHpsDay, oracleUnitHpsDay] = await this.publicClient.multicall({ allowFailure: false, contracts: [ @@ -116,6 +119,16 @@ export class PerpsVenueAdapter implements VenueAdapter { functionName: "decimals", }, { address: token, abi: erc20Abi, functionName: "decimals" }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }, ], }); if (tokenDecimals > oracleDecimals) { @@ -126,6 +139,8 @@ export class PerpsVenueAdapter implements VenueAdapter { return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + contractSizeHpsDay, + oracleUnitHpsDay, }; }, }); @@ -138,9 +153,14 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.instrumentSingleton; } + /** Perps is single-instrument; the list is always one element. */ + async listInstruments(): Promise { + return [await this.getInstrument()]; + } + async multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint } = {}, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, ): Promise<`0x${string}`> { try { return await this.wallet.walletClient.writeContract({ @@ -151,6 +171,7 @@ export class PerpsVenueAdapter implements VenueAdapter { account: this.wallet.account, chain: this.chain, maxFeePerGas: opts.maxFeePerGas, + nonce: opts.nonce, }); } catch (err) { // Attach a Tenderly simulation URL so the failed multicall can be @@ -228,6 +249,26 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** + * Assert the compiled `QUANTITY_DECIMALS` matches the on-chain + * `HashPowerPerpsDEX.QUANTITY_DECIMALS()`. The off-chain sizing/notional math + * hardcodes this scale for performance, so the chain is the source of truth — + * a mismatch (e.g. after a venue redeploy) must fail fast at startup rather + * than silently misprice by orders of magnitude. + */ + async validateQuantityDecimals(): Promise { + const onChain = (await this.publicClient.readContract({ + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "QUANTITY_DECIMALS", + })) as number; + if (Number(onChain) !== QUANTITY_DECIMALS) { + throw new Error( + `perps: on-chain QUANTITY_DECIMALS (${onChain}) != market-maker QUANTITY_DECIMALS (${QUANTITY_DECIMALS})`, + ); + } + } + async fetchImSpotShock(): Promise { if (this.imSpotShockCache !== null) return this.imSpotShockCache; const { engine } = await this.resolveAddresses(); @@ -251,93 +292,66 @@ export class PerpsVenueAdapter implements VenueAdapter { * `deposit(amount)` is delegated to the shared `vaultDeposit` helper; the * old `addCollateralWithPermit` path no longer exists on the contract. */ -class PerpsCollateralAccount implements CollateralAccount { +class PerpsCollateralAccount implements BatchableCollateralAccount { private readonly venue: PerpsVenueAdapter; constructor(venue: PerpsVenueAdapter) { this.venue = venue; } - async snapshot(): Promise { + /** + * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads + * so the portfolio aggregator can batch every venue into one multicall. + * `shared` order is canonical across venues: + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + */ + async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; const { vault, engine, token } = await this.venue.resolveAddresses(); const mc3 = await this.venue.getMulticall3Address(); - const [ - vaultBalance, - portfolioIM, - portfolioMM, - orderMargin, - perpsUnrealizedPnl, - pendingFunding, - walletTokenBalance, - nativeBalance, - ] = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: vault, - abi: CollateralVaultAbi, - functionName: "balanceOf", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioIM", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioMM", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getOrderMargin", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getUnrealizedPnl", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getPendingFunding", - args: [owner], - }, - { - address: token, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }, - { - address: mc3, - abi: Multicall3Abi, - functionName: "getEthBalance", - args: [owner], - }, - ], - }); + const shared = [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ] as MarginReadPlan["shared"]; - // Funding owed (positive) reduces effective unrealized PnL. - const venueUnrealizedPnl = perpsUnrealizedPnl - pendingFunding; + const venue = [ + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getOrderMargin", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, + ] as MarginReadPlan["venue"]; - return { - vaultBalance, - portfolioIM, - portfolioMM, - venueOrderMargin: orderMargin, - venueUnrealizedPnl, - walletTokenBalance, - nativeBalance, - collateralToken: token, + const decode = (results: readonly unknown[]): CollateralSnapshot => { + const r = results as bigint[]; + const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; + const orderMargin = r[5]; + const perpsUnrealizedPnl = r[6]; + const pendingFunding = r[7]; + // Funding owed (positive) reduces effective unrealized PnL. + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: orderMargin, + venueUnrealizedPnl: perpsUnrealizedPnl - pendingFunding, + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; }; + + return { shared, venue, decode }; + } + + async snapshot(): Promise { + const plan = await this.buildMarginReadPlan(); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [...plan.shared, ...plan.venue], + }); + return plan.decode(results); } imSpotShock(): Promise { diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts index f3743b3..ff3421d 100644 --- a/market-maker/src/core/rawOracle.ts +++ b/market-maker/src/core/rawOracle.ts @@ -7,16 +7,17 @@ * 2-tick floor on the symmetric bid/ask layout. * * `RawOracleReader` reads the underlying Chainlink aggregator directly and - * applies the same `10^(oracle.decimals − token.decimals)` rebase the contract - * does, but skips the tick rounding. The MM gets a unit-precision mid that - * lands between ticks ~99% of the time, so `roundDownToTick(r) → bidMid` and + * applies the same `10^(oracle.decimals − token.decimals)` rebase AND the same + * contract-size multiplier (`contractSizeHpsDay / ORACLE_UNIT_HPS_DAY`) the venue does, + * but skips the tick rounding. The MM gets a unit-precision mid that lands + * between ticks ~99% of the time, so `roundDownToTick(r) → bidMid` and * `roundUpToTick(r) → askMid` produce a 1-tick spread without any extra * pricing-strategy plumbing. * - * The two venues differ only in *how* the (oracle address, scaling divisor) - * pair is discovered. Each adapter supplies that as a `resolve()` callback; - * the reader caches the result for the lifetime of the process (both values - * change only on `setOracle`-style admin txs). + * The two venues differ only in *how* the (oracle address, scaling divisor, + * contract-size multiplier) tuple is discovered. Each adapter supplies that as a + * `resolve()` callback; the reader caches the result for the lifetime of the + * process (all three change only on `setOracle`/`setContractSize`-style admin txs). */ import type { PublicClient } from "viem"; @@ -49,6 +50,10 @@ export interface RawOracleConfig { oracle: `0x${string}`; /** 10^(oracle.decimals − token.decimals); used to rebase the answer to token decimals. */ divisor: bigint; + /** Contract size in hashes/s·day (`contractSizeHpsDay`). Numerator of the unit rebase. */ + contractSizeHpsDay: bigint; + /** The oracle's quote basis in hashes/s·day (`ORACLE_UNIT_HPS_DAY`). Denominator of the unit rebase. */ + oracleUnitHpsDay: bigint; } export class RawOracleReader { @@ -83,7 +88,9 @@ export class RawOracleReader { if (answer <= 0n) { throw new Error(`${this.label}: oracle returned non-positive answer (${answer.toString()})`); } - return answer / this.cache.divisor; + // Mirror the venue's `getMarketPrice()`: rebase decimals first, then apply the + // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). + return ((answer / this.cache.divisor) * this.cache.contractSizeHpsDay) / this.cache.oracleUnitHpsDay; } /** Drop cached (oracle, divisor) — next `read()` will re-resolve. */ From fb67eca08f190320d630deb0c7fb2c0cd610ceca Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 8 Jul 2026 17:13:42 +0200 Subject: [PATCH 080/180] chore(market-maker): include in-progress portfolio market-maker work Separate, unrelated in-progress portfolio market-maker feature (portfolio runner, circuit breaker, nonce/tx coordination, collateral/health tracking, schemas and config) committed as-is per request. Not part of the PH/s contract-unit change. --- market-maker/.gitignore | 1 + market-maker/configs/portfolio.local.yml | 117 ++ market-maker/package.json | 9 +- market-maker/schemas/portfolio.json | 1482 +++++++++++++++++ market-maker/scripts/gen-schemas.ts | 2 + market-maker/src/adapters/futures/events.ts | 23 +- market-maker/src/adapters/futures/index.ts | 7 +- .../src/adapters/futures/instrument.ts | 327 ++-- .../src/adapters/futures/ownOrders.ts | 57 +- market-maker/src/adapters/perps/index.ts | 5 +- market-maker/src/apps/futures/config.ts | 4 +- market-maker/src/apps/perps/config.ts | 4 +- market-maker/src/apps/portfolio/config.ts | 361 ++++ market-maker/src/apps/portfolio/main.ts | 318 ++++ market-maker/src/core/adapter.ts | 62 +- market-maker/src/core/circuitBreaker.ts | 63 + market-maker/src/core/inventoryManager.ts | 5 + market-maker/src/core/marketRuntime.ts | 179 ++ market-maker/src/core/math.ts | 4 + market-maker/src/core/nonceManager.ts | 194 +++ market-maker/src/core/orderExecutor.ts | 46 +- market-maker/src/core/portfolioCollateral.ts | 95 ++ market-maker/src/core/portfolioHealth.ts | 166 ++ market-maker/src/core/portfolioRunner.ts | 363 ++++ market-maker/src/core/quoter.ts | 5 +- market-maker/src/core/riskManager.ts | 26 +- market-maker/src/core/txCoordinator.ts | 168 ++ .../tests/apps/portfolio/config.test.ts | 159 ++ .../tests/core/circuitBreaker.test.ts | 55 + market-maker/tests/core/config/base.test.ts | 90 + market-maker/tests/core/gasTracker.test.ts | 75 + market-maker/tests/core/marketRuntime.test.ts | 193 +++ market-maker/tests/core/nonceManager.test.ts | 265 +++ market-maker/tests/core/orderExecutor.test.ts | 161 ++ .../tests/core/portfolioCollateral.test.ts | 151 ++ .../tests/core/portfolioRunner.test.ts | 395 +++++ market-maker/tests/core/riskManager.test.ts | 32 + market-maker/tests/core/txCoordinator.test.ts | 163 ++ 38 files changed, 5581 insertions(+), 251 deletions(-) create mode 100644 market-maker/.gitignore create mode 100644 market-maker/configs/portfolio.local.yml create mode 100644 market-maker/schemas/portfolio.json create mode 100644 market-maker/src/apps/portfolio/config.ts create mode 100644 market-maker/src/apps/portfolio/main.ts create mode 100644 market-maker/src/core/circuitBreaker.ts create mode 100644 market-maker/src/core/marketRuntime.ts create mode 100644 market-maker/src/core/nonceManager.ts create mode 100644 market-maker/src/core/portfolioCollateral.ts create mode 100644 market-maker/src/core/portfolioHealth.ts create mode 100644 market-maker/src/core/portfolioRunner.ts create mode 100644 market-maker/src/core/txCoordinator.ts create mode 100644 market-maker/tests/apps/portfolio/config.test.ts create mode 100644 market-maker/tests/core/circuitBreaker.test.ts create mode 100644 market-maker/tests/core/config/base.test.ts create mode 100644 market-maker/tests/core/marketRuntime.test.ts create mode 100644 market-maker/tests/core/nonceManager.test.ts create mode 100644 market-maker/tests/core/portfolioCollateral.test.ts create mode 100644 market-maker/tests/core/portfolioRunner.test.ts create mode 100644 market-maker/tests/core/txCoordinator.test.ts diff --git a/market-maker/.gitignore b/market-maker/.gitignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/market-maker/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml new file mode 100644 index 0000000..6a2e3d1 --- /dev/null +++ b/market-maker/configs/portfolio.local.yml @@ -0,0 +1,117 @@ +# yaml-language-server: $schema=../schemas/portfolio.json +# Titan Market Maker - Portfolio (perps + all futures expiries) - LOCAL (hardhat). +# +# PRIVATE_KEY - hex private key of the single market-making wallet +# PERPS_ADDRESS - HashPowerPerpsDEX address on the local chain +# FUTURES_ADDRESS - Futures address on the local chain +# +# One process, one signer, one shared collateral vault. Perps and every +# selected futures expiry quote together; the TxCoordinator sequences their +# txs on a single nonce and each market is isolated behind its own circuit +# breaker. + +nodeEnv: development +commitHash: ${COMMIT_HASH:-dev} +logLevel: debug +dryRun: false +cancelOrdersOnShutdown: false + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +# Single shared signer for the whole portfolio. +wallet: primary + +network: + name: "hardhat" + rpcUrl: "http://127.0.0.1:8545" + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venues: + - kind: perps + address: ${PERPS_ADDRESS} + maxPositionSize: 10 + pricing: + strategy: effective-spread + minSpreadBps: 20 + volatilityMultiplier: 2.5 + inventorySkewGamma: 1.0 + maxSkewTicks: 5 + sizing: + strategy: linear + baseQuantity: "10000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 3 + + - kind: futures + address: ${FUTURES_ADDRESS} + maxPositionSize: 10 + # Quote the three nearest expiries; the roll adds/drops markets as dates mature. + marketSelection: + mode: nearest + count: 3 + pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 20 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + sizing: + strategy: geometric-taper + baseQuantity: "10000000" # venue-native (contracts base units) + numLevelsPerSide: 3 + taperRatio: 0.6 + +# Shared portfolio-wide budget across every market. +risk: + maxPositionSize: 10 + maxUtilizationPct: 80 + minCollateralBalance: 1 + maxDailyLossUsd: 100 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 3 + requoteThresholdTicks: 2 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + +collateral: + autoDeposit: false + autoDepositMinAmount: 0 + +oracle: + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + +health: + port: 3001 + +# Centralized submission / nonce recovery. +txCoordinator: + maxCallsPerTx: 50 + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 20 diff --git a/market-maker/package.json b/market-maker/package.json index 0c74965..e2c578a 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -8,20 +8,27 @@ }, "scripts": { "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test:coverage": "pnpm node --test --test-force-exit --test-concurrency=1 --experimental-test-coverage --test-coverage-include='src/**' 'tests/**/*.test.ts'", + "test:coverage:lcov": "mkdir -p coverage && pnpm node --test --test-force-exit --test-concurrency=1 --experimental-test-coverage --test-coverage-include='src/**' --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'", "gen:schemas": "pnpm node scripts/gen-schemas.ts", "pretypecheck": "pnpm gen:schemas", "typecheck": "tsgo --noEmit", "node": "node --import=amaro/strip --conditions=typescript", "perps": "pnpm node --watch src/apps/perps/main.ts", "futures": "pnpm node --watch src/apps/futures/main.ts", + "portfolio": "pnpm node --watch src/apps/portfolio/main.ts", "local:perps": "pnpm perps --config configs/perps.local.yml | pino-pretty", "local:futures": "pnpm futures --config configs/futures.local.yml | pino-pretty", + "local:portfolio": "pnpm portfolio --config configs/portfolio.local.yml | pino-pretty", "dev:perps": "pnpm perps --config configs/perps.dev.yml | pino-pretty", "dev:futures": "pnpm futures --config configs/futures.dev.yml | pino-pretty", + "dev:portfolio": "pnpm portfolio --config configs/portfolio.dev.yml | pino-pretty", "stg:perps": "pnpm perps --config configs/perps.stg.yml", "stg:futures": "pnpm futures --config configs/futures.stg.yml", + "stg:portfolio": "pnpm portfolio --config configs/portfolio.stg.yml", "prd:perps": "pnpm perps --config configs/perps.prd.yml", - "prd:futures": "pnpm futures --config configs/futures.prd.yml" + "prd:futures": "pnpm futures --config configs/futures.prd.yml", + "prd:portfolio": "pnpm portfolio --config configs/portfolio.prd.yml" }, "dependencies": { "@sinclair/typebox": "^0.34.49", diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json new file mode 100644 index 0000000..9ebdf9a --- /dev/null +++ b/market-maker/schemas/portfolio.json @@ -0,0 +1,1482 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Titan Market Maker - Portfolio config", + "additionalProperties": false, + "description": "Titan Market Maker — unified portfolio app config.", + "type": "object", + "required": [ + "nodeEnv", + "commitHash", + "logLevel", + "dryRun", + "cancelOrdersOnShutdown", + "wallets", + "wallet", + "network", + "venues", + "risk", + "gas", + "collateral", + "oracle", + "timing", + "health", + "rollCheckIntervalSec", + "sharedStalenessGraceSec", + "readBatchSize", + "writeBatchSize" + ], + "properties": { + "nodeEnv": { + "default": "development", + "type": "string" + }, + "commitHash": { + "default": "unknown", + "type": "string" + }, + "logLevel": { + "default": "info", + "type": "string" + }, + "dryRun": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "cancelOrdersOnShutdown": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": true + }, + "wallets": { + "description": "Named signer wallets; `wallet` selects the portfolio signer.", + "type": "object", + "patternProperties": { + "^(.*)$": { + "additionalProperties": false, + "description": "Named signer wallet. Referenced by venue.wallet.", + "type": "object", + "required": [ + "privateKey" + ], + "properties": { + "privateKey": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Hex-encoded ECDSA private key for the signer." + } + } + } + } + }, + "wallet": { + "description": "Key in `wallets` for the single shared signer. All venues submit through this one account/nonce.", + "type": "string" + }, + "network": { + "additionalProperties": false, + "description": "Network connection settings.", + "type": "object", + "required": [ + "name", + "rpcUrl" + ], + "properties": { + "name": { + "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", + "type": "string" + }, + "rpcUrl": { + "description": "JSON-RPC endpoint URL for reads and tx submission.", + "type": "string" + }, + "ethPriceFeed": { + "description": "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", + "anyOf": [ + { + "const": "", + "type": "string" + }, + { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "venues": { + "minItems": 1, + "description": "Venues to run in this process (perps and/or futures).", + "type": "array", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "description": "Perps venue in the portfolio.", + "type": "object", + "required": [ + "kind", + "address", + "maxPositionSize", + "pricing", + "sizing" + ], + "properties": { + "kind": { + "const": "perps", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Deployed HashPowerPerpsDEX address." + }, + "maxPositionSize": { + "description": "USD. Per-venue net position cap for perps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "pricing": { + "additionalProperties": false, + "description": "Effective-spread pricing parameters.", + "type": "object", + "required": [ + "strategy", + "minSpreadBps", + "volatilityMultiplier", + "inventorySkewGamma", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "description": "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", + "const": "effective-spread", + "type": "string" + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier applied to realized volatility when widening the spread." + }, + "inventorySkewGamma": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Inventory skew coefficient. Quotes shift by γ × (netPos / maxPos) ticks toward unwinding." + }, + "maxSkewTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Cap on absolute ticks a level can be skewed from the symmetric mid." + } + } + }, + "sizing": { + "additionalProperties": false, + "description": "Linear-ladder sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", + "const": "linear", + "type": "string" + }, + "baseQuantity": { + "description": "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of price levels quoted per side." + } + } + } + } + }, + { + "additionalProperties": false, + "description": "Futures venue (one market per selected expiry).", + "type": "object", + "required": [ + "kind", + "address", + "maxPositionSize", + "pricing", + "sizing" + ], + "properties": { + "kind": { + "const": "futures", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Deployed Futures address." + }, + "maxPositionSize": { + "description": "USD. Per-expiry net position cap for futures markets.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "marketSelection": { + "description": "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + "anyOf": [ + { + "additionalProperties": false, + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "nearest", + "type": "string" + }, + "count": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + }, + { + "additionalProperties": false, + "type": "object", + "required": [ + "mode", + "indices" + ], + "properties": { + "mode": { + "const": "indices", + "type": "string" + }, + "indices": { + "type": "array", + "items": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + } + } + ] + }, + "pricing": { + "additionalProperties": false, + "description": "Reservation-price pricing parameters.", + "type": "object", + "required": [ + "strategy", + "riskAversion", + "marginCallTimeSec", + "minSpreadBps", + "volatilityMultiplier", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "description": "Pricing strategy. Futures lock to 'reservation-price' (Avellaneda–Stoikov inventory skew).", + "const": "reservation-price", + "type": "string" + }, + "riskAversion": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Avellaneda–Stoikov risk aversion γ. Higher = stronger inventory skew." + }, + "marginCallTimeSec": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable." + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier applied to realized volatility when widening the spread." + }, + "maxSkewTicks": { + "const": 0, + "default": 0, + "description": "Pinned to 0 — under reservation-price the skew is encoded in r itself.", + "type": "number" + } + } + }, + "sizing": { + "additionalProperties": false, + "description": "Geometric-taper sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide", + "taperRatio" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Futures lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", + "type": "string" + }, + "baseQuantity": { + "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of price levels quoted per side." + }, + "taperRatio": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." + } + } + } + } + } + ] + } + }, + "risk": { + "additionalProperties": false, + "description": "Risk caps, circuit-breakers, and gas-price guards.", + "type": "object", + "required": [ + "maxPositionSize", + "maxUtilizationPct", + "minCollateralBalance", + "maxDailyLossUsd", + "maxGasBudgetPerHourUsd", + "maxGasBudgetPerDayUsd", + "gasSpikeThresholdPct", + "gasPenaltyBps", + "urgentRequoteThresholdTicks" + ], + "properties": { + "maxPositionSize": { + "description": "USD. Hard cap on |net position notional|. Beyond this, only risk-reducing quotes are placed.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxUtilizationPct": { + "anyOf": [ + { + "minimum": 0, + "maximum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Margin utilization (used IM / vault balance) above which only risk-reducing quotes are placed.", + "default": 80 + }, + "minCollateralBalance": { + "description": "USD. Operational floor; halts quoting when vault balance falls below this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxDailyLossUsd": { + "description": "USD. Daily PnL circuit-breaker. Halts quoting when realized loss + gas exceeds this since 00:00 UTC.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxGasBudgetPerHourUsd": { + "default": 50, + "description": "USD. Soft throttle: when hourly gas spend exceeds this, requote cooldown triples.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxGasBudgetPerDayUsd": { + "default": 500, + "description": "USD. Hard halt: stops requoting once daily gas spend exceeds this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "gasSpikeThresholdPct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Percent of baseline. Quotes pause when current gas price exceeds (baseline × pct/100).", + "default": 200 + }, + "gasPenaltyBps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Bps to widen spreads by per unit of gas-cost-as-fraction-of-notional (compensates for fill economics).", + "default": 5 + }, + "urgentRequoteThresholdTicks": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", + "default": 10 + } + } + }, + "gas": { + "additionalProperties": false, + "description": "Gas-pricing knobs.", + "type": "object", + "required": [ + "gasCapMultiplier" + ], + "properties": { + "gasCapMultiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier on viem-suggested gas price for the maxFeePerGas cap. Higher = more reliable inclusion at higher cost.", + "default": 2 + } + } + }, + "collateral": { + "additionalProperties": false, + "description": "Collateral vault behaviour.", + "type": "object", + "required": [ + "autoDeposit", + "autoDepositMinAmount" + ], + "properties": { + "autoDeposit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "If true, sweeps wallet token balance into the vault on each loop iteration (subject to min/max).", + "default": false + }, + "autoDepositMinAmount": { + "default": 0, + "description": "USD. Trigger threshold: deposit fires only when wallet balance ≥ this. Dust filter to avoid wasting gas on tiny sweeps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxCollateralAmount": { + "description": "USD. Optional ceiling on the total vault balance held by this MM. Each auto-deposit brings the vault up to (but not above) this value; the wallet retains anything beyond it. Omit for no ceiling.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "oracle": { + "additionalProperties": false, + "description": "OracleTracker / volatility-window configuration.", + "type": "object", + "required": [ + "windowSize", + "precisionBits", + "historyLookbackMultiplier" + ], + "properties": { + "windowSize": { + "anyOf": [ + { + "minimum": 3, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "default": 60 + }, + "precisionBits": { + "anyOf": [ + { + "minimum": 16, + "maximum": 256, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", + "default": 48 + }, + "historyLookbackMultiplier": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", + "default": 4 + }, + "history": { + "additionalProperties": false, + "description": "Historical price source for σ window backfill.", + "type": "object", + "required": [ + "subgraphUrl" + ], + "properties": { + "subgraphUrl": { + "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", + "type": "string" + } + } + } + } + }, + "timing": { + "additionalProperties": false, + "description": "Loop cadences and requote thresholds.", + "type": "object", + "required": [ + "pollIntervalSec", + "requoteThresholdTicks", + "requoteCooldownSec", + "resyncIntervalSec", + "levelSpacingTicks" + ], + "properties": { + "pollIntervalSec": { + "default": 3, + "description": "Seconds between main-loop iterations (snapshot, quote, execute).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0.1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "requoteThresholdTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Tick deviation from current target before a resting order is replaced.", + "default": 2 + }, + "requoteCooldownSec": { + "default": 1, + "description": "Seconds between requote bursts. Tripled when risk is throttled.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "resyncIntervalSec": { + "default": 60, + "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "levelSpacingTicks": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", + "default": 1 + } + } + }, + "health": { + "additionalProperties": false, + "description": "Health-check HTTP server.", + "type": "object", + "required": [ + "port" + ], + "properties": { + "port": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "TCP port for the /healthz HTTP endpoint.", + "default": 3001 + } + } + }, + "txCoordinator": { + "additionalProperties": false, + "default": {}, + "description": "Centralized submission / nonce recovery.", + "type": "object", + "required": [ + "maxCallsPerTx", + "confirmationTimeoutSec", + "maxReplacements", + "replacementFeeBumpPct" + ], + "properties": { + "maxCallsPerTx": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Max encoded multicall entries per tx before chunking.", + "default": 50 + }, + "confirmationTimeoutSec": { + "default": 60, + "description": "Seconds to wait for a tx receipt before replacing by fee.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxReplacements": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Replacement-by-fee attempts before escalating to a cancel-tx.", + "default": 2 + }, + "replacementFeeBumpPct": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Fee bump per replacement attempt, percent.", + "default": 15 + } + } + }, + "circuitBreaker": { + "additionalProperties": false, + "default": {}, + "description": "Per-market circuit-breaker tuning.", + "type": "object", + "required": [ + "quarantineThreshold", + "baseBackoffSec", + "maxBackoffSec" + ], + "properties": { + "quarantineThreshold": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Consecutive market errors before quarantine.", + "default": 3 + }, + "baseBackoffSec": { + "default": 5, + "description": "Base quarantine backoff (seconds).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxBackoffSec": { + "default": 180, + "description": "Backoff ceiling (seconds).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "rollCheckIntervalSec": { + "default": 300, + "description": "Seconds between futures roll re-checks (add/drop expiries).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "sharedStalenessGraceSec": { + "default": 30, + "description": "Seconds shared inputs may be stale before new placements are paused (existing orders kept).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "readBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 10 + }, + "writeBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 20 + } + } +} diff --git a/market-maker/scripts/gen-schemas.ts b/market-maker/scripts/gen-schemas.ts index 7d822b6..e78880f 100644 --- a/market-maker/scripts/gen-schemas.ts +++ b/market-maker/scripts/gen-schemas.ts @@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { perpsRootSchema } from "../src/apps/perps/config.ts"; import { futuresRootSchema } from "../src/apps/futures/config.ts"; +import { portfolioRootSchema } from "../src/apps/portfolio/config.ts"; const here = dirname(fileURLToPath(import.meta.url)); const outDir = resolve(here, "..", "schemas"); @@ -83,6 +84,7 @@ function relaxForEnvInterpolation(node: unknown): unknown { const targets = [ { name: "perps", schema: perpsRootSchema, title: "Titan Market Maker - Perps config" }, { name: "futures", schema: futuresRootSchema, title: "Titan Market Maker - Futures config" }, + { name: "portfolio", schema: portfolioRootSchema, title: "Titan Market Maker - Portfolio config" }, ] as const; for (const t of targets) { diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 30ead7f..006e799 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -6,7 +6,10 @@ import type { } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; -export const FUTURES_INSTRUMENT_ID = "futures"; +/** Instrument id for a futures expiry, e.g. `futures:1893456000`. */ +export function futuresInstrumentId(deliveryDate: bigint): string { + return `futures:${deliveryDate.toString()}`; +} type FuturesLog = Log< bigint, @@ -61,11 +64,12 @@ export class FuturesVenueEvents implements VenueEvents { export function decodeEvent(log: FuturesLog): VenueEvent | null { switch (log.eventName) { case "OrderCreated": { - const { orderId, participant, pricePerDay, isBuy } = log.args; + const { orderId, participant, pricePerDay, deliveryAt, isBuy } = log.args; if ( !orderId || !participant || pricePerDay === undefined || + deliveryAt === undefined || isBuy === undefined ) return null; @@ -75,33 +79,34 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { participant, price: pricePerDay, side: isBuy ? "buy" : "sell", - size: 1n, // futures orders are always single-contract per OrderCreated event - instrumentId: FUTURES_INSTRUMENT_ID, + size: 1n, // futures orders are single-contract per OrderCreated event + instrumentId: futuresInstrumentId(deliveryAt), + deliveryDate: deliveryAt, }; } case "OrderClosed": { const { orderId } = log.args; if (!orderId) return null; + // OrderClosed carries neither participant nor deliveryAt; per-expiry + // own-order caches resolve ownership + routing by cache membership. return { type: "order-cancelled", orderId, - instrumentId: FUTURES_INSTRUMENT_ID, }; } case "LotCreated": { - const { lotId, seller, buyer } = log.args; - if (!lotId || !seller || !buyer) return null; + const { seller, deliveryAt } = log.args; + if (!seller || deliveryAt === undefined) return null; return { type: "position-changed", participant: seller, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: futuresInstrumentId(deliveryAt), }; } case "LotClosed": return { type: "position-changed", participant: "0x0" as `0x${string}`, - instrumentId: FUTURES_INSTRUMENT_ID, }; default: return null; diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index a65c0a1..6fe911a 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -1,7 +1,7 @@ import type pino from "pino"; import type { NetworkClients } from "../../core/client.ts"; import type { VenueAdapter, WalletContext } from "../../core/adapter.ts"; -import { FuturesVenueAdapter } from "./venue.ts"; +import { FuturesVenueAdapter, type FuturesMarketSelection } from "./venue.ts"; export interface CreateFuturesVenueOpts { network: NetworkClients; @@ -10,6 +10,8 @@ export interface CreateFuturesVenueOpts { multicall3Address?: `0x${string}`; readBatchSize: number; writeBatchSize: number; + /** Which delivery dates to quote. Defaults to nearest-only. */ + marketSelection?: FuturesMarketSelection; logger: pino.Logger; } @@ -25,4 +27,5 @@ export async function createFuturesVenue( } export { FuturesVenueAdapter } from "./venue.ts"; -export { FuturesInstrumentAdapter } from "./instrument.ts"; +export type { FuturesMarketSelection, FuturesMarketSet } from "./venue.ts"; +export { FuturesInstrumentAdapter, futuresInstrumentId } from "./instrument.ts"; diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 0a6a984..5dfed27 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -16,85 +16,123 @@ import type { import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; +import { futuresInstrumentId } from "./events.ts"; -const FUTURES_INSTRUMENT_ID = "futures"; +export { futuresInstrumentId } from "./events.ts"; +/** + * One futures market = one delivery date (expiry). The venue creates one + * adapter per selected expiry; each owns its own book snapshot, own-order + * cache, and order encoding, all scoped to `deliveryDate`. + * + * Position and margin reads are per-expiry (client-side), while the shared + * portfolio collateral/IM/MM lives on the venue's `CollateralAccount`. + */ export class FuturesInstrumentAdapter implements InstrumentAdapter { - readonly id = FUTURES_INSTRUMENT_ID; + readonly id: string; readonly venue: FuturesVenueAdapter; readonly book: FuturesBook; readonly ownOrders: FuturesOwnOrders; + readonly deliveryDate: bigint; private tickCache: bigint | null = null; - private deliveryDateCache: bigint | null = null; - private deliveryDurationDaysCache: bigint | null = null; + private marginPercentCache: bigint | null = null; - constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { + constructor(venue: FuturesVenueAdapter, deliveryDate: bigint, logger: pino.Logger) { this.venue = venue; + this.deliveryDate = deliveryDate; + this.id = futuresInstrumentId(deliveryDate); this.book = new FuturesBook(this, venue.readBatchSize); - this.ownOrders = new FuturesOwnOrders(venue, logger, venue.readBatchSize); + this.ownOrders = new FuturesOwnOrders( + venue, + deliveryDate, + logger.child({ instrument: this.id }), + venue.readBatchSize, + ); } async getIndexPrice(): Promise { - // Read the raw oracle answer rebased to token decimals — `Futures.getMarketPrice` - // would round to the nearest tick, which collapses our reservation-price - // shift onto a tick boundary and forces a 2-tick min spread. The unrounded - // mid lets `roundDownToTick(r) → bidMid` and `roundUpToTick(r) → askMid` - // produce a 1-tick spread naturally. + // Raw oracle answer rebased to token decimals (no tick rounding). All + // futures expiries share the same per-day hashprice oracle, so the index + // is identical across markets; only time-to-expiry (T) differs downstream. return await this.venue.getRawMarketPrice(); } async getPosition(): Promise { - // Futures' net position is summed from all open positions. We use the - // engine view exposed for this purpose: getNetPositionDelta returns - // `Σ qty_i * deliveryDurationDays` × 1e18 in WAD. Convert back to - // contracts by dividing by `deliveryDurationDays * 1e18`. - const [netDeltaWad, durationDays, marketPrice] = await Promise.all([ - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "getNetPositionDelta", - args: [this.venue.wallet.account.address], - }), - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - }), - this.venue.getRawMarketPrice(), - ]); - const days = BigInt(durationDays); - const denom = days * 10n ** 18n; - const netQuantity = denom === 0n ? 0n : netDeltaWad / denom; - return { netQuantity, entryPrice: marketPrice }; - } - - async getContext(): Promise { - const deliveryDates = await this.venue.publicClient.readContract({ + // Per-expiry net position, computed client-side. The engine's + // `getNetPositionDelta` is portfolio-wide (sums all expiries), so we walk + // this expiry's positions instead. Each position is a single matched unit + // (qty=1): buyer is long (+1), seller is short (-1). + const owner = this.venue.wallet.account.address.toLowerCase(); + const positionIds = await this.venue.publicClient.readContract({ address: this.venue.address, abi: FuturesAbi, - functionName: "getDeliveryDates", + functionName: "getPositionsByParticipantDeliveryDate", + args: [this.venue.wallet.account.address, this.deliveryDate], }); - if (deliveryDates.length === 0) - throw new Error("futures contract returned no delivery dates"); - this.deliveryDateCache = deliveryDates[0]; - // Eagerly cache margin inputs so `estimateOrderMargin` can be synchronous. - const { deliveryDurationDays } = await this.venue.getMarginInputs(); - this.deliveryDurationDaysCache = deliveryDurationDays; + if (positionIds.length === 0) { + return { netQuantity: 0n, entryPrice: await this.venue.getRawMarketPrice() }; + } + + const batchSize = this.venue.readBatchSize; + const positions: { + seller: string; + buyer: string; + sellPricePerDay: bigint; + buyPricePerDay: bigint; + }[] = []; + for (let i = 0; i < positionIds.length; i += batchSize) { + const chunk = positionIds.slice(i, i + batchSize); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: chunk.map((id) => ({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + }); + positions.push( + ...(results as { + seller: string; + buyer: string; + sellPricePerDay: bigint; + buyPricePerDay: bigint; + }[]), + ); + } + let net = 0n; + let entrySum = 0n; + let entryCount = 0n; + for (const p of positions) { + if (p.buyer.toLowerCase() === owner) { + net += 1n; + entrySum += p.buyPricePerDay; + entryCount += 1n; + } + if (p.seller.toLowerCase() === owner) { + net -= 1n; + entrySum += p.sellPricePerDay; + entryCount += 1n; + } + } + const entryPrice = + entryCount > 0n ? entrySum / entryCount : await this.venue.getRawMarketPrice(); + return { netQuantity: net, entryPrice }; + } + + async getContext(): Promise { + // Eagerly cache marginPct so `estimateOrderMargin` is synchronous. + const { marginPct } = await this.venue.getMarginInputs(); + this.marginPercentCache = marginPct; return { - deliveryDate: Number(deliveryDates[0]), - contractMultiplier: deliveryDurationDays, + deliveryDate: Number(this.deliveryDate), }; } encodeCreate(intent: OrderIntent): `0x${string}` { - if (this.deliveryDateCache === null) { - throw new Error( - "futures: getContext() must be called before encodeCreate()", - ); - } const qty = Number(intent.size); if (qty <= 0 || qty > 127) { throw new Error(`futures: order size ${qty} must be in (0, 127]`); @@ -105,7 +143,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { return encodeFunctionData({ abi: FuturesAbi, functionName: "createOrder", - args: [intent.price, this.deliveryDateCache, "", signed], + args: [intent.price, this.deliveryDate, "", signed], }); } @@ -118,58 +156,50 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** - * Execute cancels then creates on-chain. Owns the full lifecycle: - * encoding → batching → tx chunking → broadcast → receipt gathering. - * - * Cancels (individual closeOrder calls, max 20 per batch) are placed - * before creates (createOrders calls, max 10 per batch) so margin is - * freed before new risk is added. + * Execute cancels then creates for this expiry. Kept for single-market + * callers and tests; the portfolio runner routes through the shared + * `TxCoordinator` instead, which batches this expiry's calls with the other + * expiries' into one `Futures.multicall`. */ - async executeOrders( - intent: ExecuteOrdersIntent, - ): Promise { + async executeOrders(intent: ExecuteOrdersIntent): Promise { return this.executeOrdersImpl(intent, this.venue.getLogger()); } + /** Build the ordered call list for this expiry: cancels then creates. */ + buildCalls(intent: { cancels: CancelIntent[]; creates: OrderIntent[] }): `0x${string}`[] { + const calls: `0x${string}`[] = []; + for (const c of intent.cancels) calls.push(this.encodeCancel(c)); + for (const c of intent.creates) calls.push(this.encodeCreate(c)); + return calls; + } + // ── Private implementation ────────────────────────────────────────── - /** - * Shared implementation — the inner `logger` param makes this testable - * without coupling to the full venue adapter. - */ private async executeOrdersImpl( intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - // 1. Build the ordered call list: cancels first, then creates. - const calls = this.buildCallList(intent); + const batches = this.chunkCalls(intent); if (intent.dryRun) { logger.info( - { - cancels: intent.cancels.length, - creates: intent.creates.length, - }, + { cancels: intent.cancels.length, creates: intent.creates.length }, "DRY RUN: would send multicall batches", ); return { receipts: [], errors: [] }; } - // 2. Chunk into tx-sized groups and broadcast sequentially. const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; const errors: Error[] = []; - const totalBatches = calls.length; - - console.log(calls); - for (let batchNum = 0; batchNum < totalBatches; batchNum++) { - const chunk = calls[batchNum]; + for (let batchNum = 0; batchNum < batches.length; batchNum++) { + const chunk = batches[batchNum]; try { const hash = await this.venue.multicall(chunk, { maxFeePerGas: intent.maxFeePerGas, }); - const receipt = await this.venue.publicClient.waitForTransactionReceipt( - { hash }, - ); + const receipt = await this.venue.publicClient.waitForTransactionReceipt({ + hash, + }); receipts.push({ gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice, @@ -177,7 +207,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { logger.info( { calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, + batch: `${batchNum}/${batches.length}`, gas: receipt.gasUsed.toString(), }, "futures multicall chunk executed", @@ -186,100 +216,53 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { const wrapped = err instanceof Error ? err : new Error(String(err)); errors.push(wrapped); logger.error( - { - err: wrapped, - calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, - }, + { err: wrapped, calls: chunk.length, batch: `${batchNum}/${batches.length}` }, "futures multicall chunk failed — continuing with next chunk", ); } } - return { receipts, errors }; } - /** Build the ordered call list: cancels (individual closeOrder) then creates (createOrders). */ - private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[][] { + /** + * Split cancels+creates into tx-sized chunks. Batch size is measured in qty + * count since one cancel costs roughly one qty=1 create. + */ + private chunkCalls(intent: { + cancels: CancelIntent[]; + creates: OrderIntent[]; + }): `0x${string}`[][] { const batchSize = this.venue.writeBatchSize; - - let batchN = 0; - let qtyCount = 0; // we limit batch by qty count, since gas cost of one cancel approx eq one create order qty=1 - const batch: `0x${string}`[][] = []; - function addTx(tx: `0x${string}`, qty: number) { - if (!batch[batchN]) { - batch[batchN] = new Array(); - } - batch[batchN].push(tx); + const batches: `0x${string}`[][] = []; + let current: `0x${string}`[] = []; + let qtyCount = 0; + const push = (tx: `0x${string}`, qty: number) => { + current.push(tx); qtyCount += qty; - if (batch[batchN].length >= batchSize) { - batchN++; + if (current.length >= batchSize || qtyCount >= batchSize) { + batches.push(current); + current = []; qtyCount = 0; } - } - - for (const c of intent.cancels) { - addTx(this.encodeCancel(c), 1); - } - - for (const c of intent.creates) { - addTx(this.encodeCreate(c), Number(c.size)); - } - - return batch; - } - - /** Encode a batch of creates via the `createOrders` contract function. */ - private encodeCreateOrders(intents: OrderIntent[]): `0x${string}` { - if (!this.deliveryDateCache) { - throw new Error("Delivery data cache not filled"); - } - const _deliveryDateCache = this.deliveryDateCache; - return encodeFunctionData({ - abi: FuturesAbi, - functionName: "createOrders", - args: [ - intents.map((i) => ({ - pricePerDay: i.price, - deliveryDate: _deliveryDateCache, - destURL: "", - qty: i.side === "buy" ? Number(i.size) : -Number(i.size), - })), - ], - }); + }; + for (const c of intent.cancels) push(this.encodeCancel(c), 1); + for (const c of intent.creates) push(this.encodeCreate(c), Number(c.size)); + if (current.length > 0) batches.push(current); + return batches; } /** - * Mirrors `Futures.getMaintenanceMarginForPosition` for a single new order: - * IM_added = pricePerDay × deliveryDurationDays × |qty| × marginPct / 100 - * - * `getFuturesOrderMargin` clamps each order's marginal contribution at 0 - * when its mark-to-market PnL exceeds maintenance (a profitable order - * locks no extra margin). We don't mirror that branch here: it would - * make the estimate sign-dependent on the live oracle, and the - * conservative "always charge full maintenance" estimate is fine because - * `engine.canPlaceOrder` is the real authority. We err on the high side - * by O(few percent), which only costs us a tiny slice of quoting capacity. + * IM added by a new order: + * pricePerDay × |qty| × marginPct / 100 (one unit, no duration multiplier) + * Conservative (ignores the profitable-order clamp); the engine's + * `canPlaceOrder` is the real authority. */ estimateOrderMargin(intent: OrderIntent): bigint { - if (this.deliveryDurationDaysCache === null) return 0n; - // marginPct is loaded lazily at first canPlace call; if we don't have it - // yet, return 0 and let the engine gate sort it out on the first tx. - const cachedMarginPct = ( - this.venue as unknown as { marginPercentCache?: bigint } - ).marginPercentCache; - if (!cachedMarginPct) return 0n; - return ( - (intent.price * - this.deliveryDurationDaysCache * - intent.size * - cachedMarginPct) / - 100n - ); + if (this.marginPercentCache === null) return 0n; + return (intent.price * intent.size * this.marginPercentCache) / 100n; } async estimateCreateGas(account: `0x${string}`): Promise { - if (this.deliveryDateCache === null) return 0n; try { return await this.venue.publicClient.estimateContractGas({ address: this.venue.address, @@ -287,7 +270,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { functionName: "createOrder", args: [ 1_000_000n, - this.deliveryDateCache, + this.deliveryDate, "", 1 as number & { readonly __int8__: true }, ], @@ -308,17 +291,9 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { this.tickCache = tick; return tick; } - - /** Internal: nearest delivery date, populated after `getContext()`. */ - getDeliveryDate(): bigint | null { - return this.deliveryDateCache; - } } -/** - * Per-(deliveryDate) book source. The MM is locked to the nearest delivery - * date — see venue header for rationale. - */ +/** Per-expiry book source. Reads the ladders for this instrument's delivery date. */ class FuturesBook implements BookSource { readonly matchingMode: MatchingMode = "exact"; private readonly inst: FuturesInstrumentAdapter; @@ -334,12 +309,7 @@ class FuturesBook implements BookSource { async snapshot(opts: { depth?: number } = {}): Promise { const v = this.inst.venue; - const dd = this.inst.getDeliveryDate(); - if (dd === null) { - throw new Error( - "futures: getContext() must be called before book.snapshot()", - ); - } + const dd = this.inst.deliveryDate; const depth = BigInt(opts.depth ?? 200); const [bidPrices, askPrices] = await v.publicClient.multicall({ @@ -360,8 +330,7 @@ class FuturesBook implements BookSource { ], }); - if (bidPrices.length === 0 && askPrices.length === 0) - return { bids: [], asks: [] }; + if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; const allCalls = [ ...bidPrices.map((p) => ({ @@ -378,7 +347,6 @@ class FuturesBook implements BookSource { })), ]; - // Chunk to stay under RPC payload / timeout limits. const batchSize = this.readBatchSize; const allResults: bigint[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { @@ -398,13 +366,8 @@ class FuturesBook implements BookSource { price: p, quantity: allResults[bidPrices.length + i], })); - // EnumerableSet returns prices in unspecified order; sort for the consumer. - const bids = bidsRaw.sort((a, b) => - a.price < b.price ? 1 : a.price > b.price ? -1 : 0, - ); - const asks = asksRaw.sort((a, b) => - a.price < b.price ? -1 : a.price > b.price ? 1 : 0, - ); + const bids = bidsRaw.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); + const asks = asksRaw.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); return { bids, asks }; } } diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 387034d..5d0ad9a 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -7,23 +7,24 @@ import type { } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; -import { FUTURES_INSTRUMENT_ID } from "./events.ts"; +import { futuresInstrumentId } from "./events.ts"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; /** - * Cache-backed own-order source for futures. - * - * Why a cache? `Futures.sol` previously had no view returning a participant's - * orders. The new `getOrderIds` view (added alongside this adapter) lets us - * skip the historical event-scan path entirely: + * Cache-backed own-order source for a single futures expiry. * - * 1. `bootstrap()` reads `getOrderIds(wallet)` and `getOrderById(id)` for - * each in one multicall, populating the cache. - * 2. `subscribe()` listens to venue events and applies adds/removes to the - * cache, then forwards the event to the registered callback. - * 3. `list()` returns `Array.from(cache.values())`. + * 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 `deliveryDate`: * - * Idempotency: bootstrap clears the cache before re-populating, so calling - * it twice is safe. + * 1. `bootstrap()` reads `getOrderIds(wallet)` + `getOrderById(id)` and + * caches the orders whose `deliveryAt === deliveryDate`. + * 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 — + * that both identifies ownership and routes to the right expiry. + * 3. `list()` returns the cache contents. */ export class FuturesOwnOrders implements OwnOrderSource { private readonly cache = new Map<`0x${string}`, OwnOrder>(); @@ -32,15 +33,20 @@ export class FuturesOwnOrders implements OwnOrderSource { private bootstrapped = false; private readonly venue: FuturesVenueAdapter; + private readonly deliveryDate: bigint; + private readonly instrumentId: string; private readonly logger: pino.Logger; private readonly readBatchSize: number; constructor( venue: FuturesVenueAdapter, + deliveryDate: bigint, logger: pino.Logger, readBatchSize: number, ) { this.venue = venue; + this.deliveryDate = deliveryDate; + this.instrumentId = futuresInstrumentId(deliveryDate); this.logger = logger.child({ component: "futures-own-orders" }); this.readBatchSize = readBatchSize; } @@ -74,10 +80,7 @@ export class FuturesOwnOrders implements OwnOrderSource { if (orderIds.length === 0) { this.bootstrapped = true; - this.logger.info( - { orders: 0 }, - "futures own-orders bootstrapped (empty)", - ); + this.logger.info({ orders: 0 }, "futures own-orders bootstrapped (empty)"); return; } @@ -88,7 +91,6 @@ export class FuturesOwnOrders implements OwnOrderSource { args: [id] as const, })); - // Chunk to stay under RPC payload / timeout limits. const batchSize = this.readBatchSize; const allOrders: unknown[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { @@ -104,25 +106,24 @@ export class FuturesOwnOrders implements OwnOrderSource { const o = allOrders[i] as { participant: string; pricePerDay: bigint; + deliveryAt: bigint; isBuy: boolean; }; - if ( - !o.participant || - o.participant === "0x0000000000000000000000000000000000000000" - ) - continue; + if (!o.participant || o.participant === ZERO_ADDRESS) continue; + // Keep only orders belonging to this expiry. + if (o.deliveryAt !== this.deliveryDate) continue; this.cache.set(orderIds[i], { orderId: orderIds[i], price: o.pricePerDay, side: o.isBuy ? "buy" : "sell", size: 1n, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: this.instrumentId, }); } this.bootstrapped = true; this.logger.info( - { orders: this.cache.size }, + { orders: this.cache.size, deliveryDate: this.deliveryDate.toString() }, "futures own-orders bootstrapped", ); } @@ -132,19 +133,21 @@ export class FuturesOwnOrders implements OwnOrderSource { return this.venue.events.subscribe((evt) => { if (evt.type === "order-created") { if (evt.participant.toLowerCase() !== own) return; + // Route by expiry: the created event carries the instrumentId. + if (evt.instrumentId !== this.instrumentId) return; const order: OwnOrder = { orderId: evt.orderId, price: evt.price, side: evt.side, size: 1n, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: this.instrumentId, }; this.cache.set(evt.orderId, order); this.notify({ type: "added", orderId: evt.orderId, order }); return; } if (evt.type === "order-cancelled") { - // OrderClosed no longer carries participant; identify own orders by cache. + // No expiry on the close event: apply only if this cache owns the id. if (!this.cache.has(evt.orderId)) return; this.cache.delete(evt.orderId); this.notify({ type: "removed", orderId: evt.orderId }); diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts index 055dd06..c101fe1 100644 --- a/market-maker/src/adapters/perps/index.ts +++ b/market-maker/src/adapters/perps/index.ts @@ -29,7 +29,10 @@ export interface CreatePerpsVenueOpts { export async function createPerpsVenue( opts: CreatePerpsVenueOpts, ): Promise { - return new PerpsVenueAdapter(opts); + const venue = new PerpsVenueAdapter(opts); + // Fail fast if the compiled quantity scale drifts from the deployed venue. + await venue.validateQuantityDecimals(); + return venue; } export { PerpsVenueAdapter } from "./venue.ts"; diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index d497e77..9072a45 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -54,7 +54,7 @@ const futuresVenueSchema = Type.Object( }, ); -const futuresPricingSchema = Type.Object( +export const futuresPricingSchema = Type.Object( { strategy: Type.Literal("reservation-price", { description: @@ -93,7 +93,7 @@ const futuresPricingSchema = Type.Object( // `baseQuantity` is venue-native (futures: contract base units). Bigint // expressed as a decimal string; numbers accepted but use strings if values // exceed Number.MAX_SAFE_INTEGER. -const futuresSizingSchema = Type.Object( +export const futuresSizingSchema = Type.Object( { strategy: Type.Literal("geometric-taper", { description: diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 4957fc5..0d0ec83 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -54,7 +54,7 @@ const perpsVenueSchema = Type.Object( }, ); -const perpsPricingSchema = Type.Object( +export const perpsPricingSchema = Type.Object( { strategy: Type.Literal("effective-spread", { description: @@ -87,7 +87,7 @@ const perpsPricingSchema = Type.Object( // `baseQuantity` is venue-native (perps: hashrate base units). It's a bigint // expressed as a decimal string; numbers are accepted but use strings if // values exceed Number.MAX_SAFE_INTEGER. -const perpsSizingSchema = Type.Object( +export const perpsSizingSchema = Type.Object( { strategy: Type.Literal("linear", { description: diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts new file mode 100644 index 0000000..aaf246c --- /dev/null +++ b/market-maker/src/apps/portfolio/config.ts @@ -0,0 +1,361 @@ +import { type Static, type TSchema, Type } from "@sinclair/typebox"; +import { + type ParsedCollateralConfig, + type ParsedOracleConfig, + type ParsedRiskConfig, + type ParsedTimingConfig, + TypeEthAddress, + collateralSchema, + configBigint, + gasSchema, + healthSchema, + loadConfigFromFile, + networkSchema, + oracleSchema, + parseCollateralConfig, + parseOracleConfig, + parseRiskConfig, + parseTimingConfig, + riskSchema, + timingSchema, + walletSchema, + USD_DECIMALS, +} from "../../core/config/base.ts"; +import { parseUsd, secondsToMs } from "../../core/config/units.ts"; +import { perpsPricingSchema, perpsSizingSchema } from "../perps/config.ts"; +import { futuresPricingSchema, futuresSizingSchema } from "../futures/config.ts"; +import type { FuturesMarketSelection } from "../../adapters/futures/index.ts"; +import { ConfigError } from "../../core/errors.ts"; + +/** + * Unified portfolio app config. + * + * A single process runs one signer wallet against N markets across venues + * (perps + all selected futures expiries). Collateral, gas, risk budget, and + * loop timing are shared; each venue carries its own pricing/sizing and a + * per-venue position cap. The futures venue additionally declares how many + * expiries to quote (`marketSelection`). + */ +const Closed = { additionalProperties: false }; + +const TypeUsdAmount = (opts?: { default?: string | number; description?: string }) => + Type.Union([Type.String({ pattern: "^-?\\d+(\\.\\d+)?$" }), Type.Number()], opts); + +const TypeSeconds = (opts?: { minimum?: number; default?: number; description?: string }) => { + const { minimum, ...rest } = opts ?? {}; + return Type.Union( + [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum })], + rest as Record, + ); +}; + +const marketSelectionSchema = Type.Union( + [ + Type.Object( + { + // `count` is optional (defaulted to 1 in parseVenue). It can't carry a + // schema `default` here: AJV skips defaults inside anyOf/oneOf branches + // and, in strict mode, errors ("default is ignored for: …count"). + mode: Type.Literal("nearest"), + count: Type.Optional(Type.Integer({ minimum: 1 })), + }, + Closed, + ), + Type.Object( + { + mode: Type.Literal("indices"), + indices: Type.Array(Type.Integer({ minimum: 0 })), + }, + Closed, + ), + ], + { + description: + "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + }, +); + +const perpsVenueSchema = Type.Object( + { + kind: Type.Literal("perps"), + address: TypeEthAddress({ description: "Deployed HashPowerPerpsDEX address." }), + maxPositionSize: TypeUsdAmount({ + description: "USD. Per-venue net position cap for perps.", + }), + pricing: perpsPricingSchema, + sizing: perpsSizingSchema, + }, + { ...Closed, description: "Perps venue in the portfolio." }, +); + +const futuresVenueSchema = Type.Object( + { + kind: Type.Literal("futures"), + address: TypeEthAddress({ description: "Deployed Futures address." }), + maxPositionSize: TypeUsdAmount({ + description: "USD. Per-expiry net position cap for futures markets.", + }), + marketSelection: Type.Optional(marketSelectionSchema), + pricing: futuresPricingSchema, + sizing: futuresSizingSchema, + }, + { ...Closed, description: "Futures venue (one market per selected expiry)." }, +); + +const txCoordinatorSchema = Type.Object( + { + maxCallsPerTx: Type.Integer({ + minimum: 1, + default: 50, + description: "Max encoded multicall entries per tx before chunking.", + }), + confirmationTimeoutSec: TypeSeconds({ + minimum: 1, + default: 60, + description: "Seconds to wait for a tx receipt before replacing by fee.", + }), + maxReplacements: Type.Integer({ + minimum: 0, + default: 2, + description: "Replacement-by-fee attempts before escalating to a cancel-tx.", + }), + replacementFeeBumpPct: Type.Number({ + minimum: 0, + default: 15, + description: "Fee bump per replacement attempt, percent.", + }), + }, + { ...Closed, default: {}, description: "Centralized submission / nonce recovery." }, +); + +const circuitBreakerSchema = Type.Object( + { + quarantineThreshold: Type.Integer({ + minimum: 1, + default: 3, + description: "Consecutive market errors before quarantine.", + }), + baseBackoffSec: TypeSeconds({ + minimum: 1, + default: 5, + description: "Base quarantine backoff (seconds).", + }), + maxBackoffSec: TypeSeconds({ + minimum: 1, + default: 180, + description: "Backoff ceiling (seconds).", + }), + }, + { ...Closed, default: {}, description: "Per-market circuit-breaker tuning." }, +); + +export const portfolioRootSchema = Type.Object( + { + nodeEnv: Type.String({ default: "development" }), + commitHash: Type.String({ default: "unknown" }), + logLevel: Type.String({ default: "info" }), + dryRun: Type.Boolean({ default: false }), + cancelOrdersOnShutdown: Type.Boolean({ default: true }), + wallets: Type.Record(Type.String(), walletSchema, { + description: "Named signer wallets; `wallet` selects the portfolio signer.", + }), + wallet: Type.String({ + description: + "Key in `wallets` for the single shared signer. All venues submit through this one account/nonce.", + }), + network: networkSchema, + venues: Type.Array(Type.Union([perpsVenueSchema, futuresVenueSchema]), { + minItems: 1, + description: "Venues to run in this process (perps and/or futures).", + }), + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + oracle: oracleSchema, + timing: timingSchema, + health: healthSchema, + txCoordinator: Type.Optional(txCoordinatorSchema), + circuitBreaker: Type.Optional(circuitBreakerSchema), + rollCheckIntervalSec: TypeSeconds({ + minimum: 1, + default: 300, + description: "Seconds between futures roll re-checks (add/drop expiries).", + }), + sharedStalenessGraceSec: TypeSeconds({ + minimum: 0, + default: 30, + description: + "Seconds shared inputs may be stale before new placements are paused (existing orders kept).", + }), + readBatchSize: Type.Number({ minimum: 1, default: 10 }), + writeBatchSize: Type.Number({ minimum: 1, default: 20 }), + }, + { ...Closed, description: "Titan Market Maker — unified portfolio app config." }, +); + +/** + * AJV (strict mode) rejects a `default` that sits inside an `anyOf`/`oneOf` + * branch because it can't decide which branch applies before validating, so + * the default would be silently ignored. The reused perps/futures pricing + * schemas legitimately carry defaults (e.g. futures `maxSkewTicks`), but once + * they're nested in the `venues` union those defaults become "ignored". We + * keep `portfolioRootSchema` (with defaults) for type inference + editor JSON + * schema, and validate against a clone with combinator-nested defaults removed. + */ +function stripCombinatorDefaults(schema: TSchema): TSchema { + const COMBINATORS = new Set(["anyOf", "oneOf", "allOf", "if", "then", "else"]); + const clone = structuredClone(schema) as unknown; + const walk = (node: unknown, inCombinator: boolean): void => { + if (Array.isArray(node)) { + for (const n of node) walk(n, inCombinator); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + if (inCombinator && "default" in obj) delete obj.default; + for (const [key, value] of Object.entries(obj)) { + walk(value, inCombinator || COMBINATORS.has(key)); + } + }; + walk(clone, false); + return clone as TSchema; +} + +const portfolioValidationSchema = stripCombinatorDefaults(portfolioRootSchema); + +type RawPortfolioConfig = Static; +type RawVenue = RawPortfolioConfig["venues"][number]; +type RawPerpsVenue = Extract; +type RawFuturesVenue = Extract; + +export interface ParsedPerpsVenue { + kind: "perps"; + address: `0x${string}`; + maxPositionSize: bigint; + pricing: RawPerpsVenue["pricing"]; + sizing: Omit & { baseQuantity: bigint }; +} + +export interface ParsedFuturesVenue { + kind: "futures"; + address: `0x${string}`; + maxPositionSize: bigint; + marketSelection: FuturesMarketSelection; + pricing: RawFuturesVenue["pricing"]; + sizing: Omit & { baseQuantity: bigint }; +} + +export type ParsedVenue = ParsedPerpsVenue | ParsedFuturesVenue; + +export interface ParsedTxCoordinatorConfig { + maxCallsPerTx: number; + confirmationTimeoutMs: number; + maxReplacements: number; + replacementFeeBumpPct: number; +} + +export interface ParsedCircuitBreakerConfig { + quarantineThreshold: number; + baseBackoffMs: number; + maxBackoffMs: number; +} + +export type PortfolioMakerConfig = Omit< + RawPortfolioConfig, + "risk" | "timing" | "collateral" | "oracle" | "venues" | "txCoordinator" | "circuitBreaker" | "rollCheckIntervalSec" | "sharedStalenessGraceSec" +> & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; + venues: ParsedVenue[]; + txCoordinator: ParsedTxCoordinatorConfig; + circuitBreaker: ParsedCircuitBreakerConfig; + rollCheckIntervalMs: number; + sharedStalenessGraceMs: number; +}; + +function parseMarketSelection(raw: RawFuturesVenue["marketSelection"]): FuturesMarketSelection { + if (!raw) return { mode: "nearest", count: 1 }; + if (raw.mode === "nearest") return { mode: "nearest", count: raw.count ?? 1 }; + return { mode: "indices", indices: raw.indices }; +} + +function parseVenue(raw: RawVenue): ParsedVenue { + if (raw.kind === "perps") { + return { + kind: "perps", + address: raw.address, + maxPositionSize: parseUsd(raw.maxPositionSize, USD_DECIMALS, "venue.maxPositionSize"), + pricing: raw.pricing, + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "venue.sizing.baseQuantity"), + }, + }; + } + return { + kind: "futures", + address: raw.address, + maxPositionSize: parseUsd(raw.maxPositionSize, USD_DECIMALS, "venue.maxPositionSize"), + marketSelection: parseMarketSelection(raw.marketSelection), + pricing: raw.pricing, + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "venue.sizing.baseQuantity"), + }, + }; +} + +export function loadPortfolioConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): PortfolioMakerConfig { + return loadConfigFromFile({ + schema: portfolioValidationSchema, + path: opts.path, + env: opts.env, + parse: (raw) => { + const tx = raw.txCoordinator ?? { + maxCallsPerTx: 50, + confirmationTimeoutSec: 60, + maxReplacements: 2, + replacementFeeBumpPct: 15, + }; + const cb = raw.circuitBreaker ?? { + quarantineThreshold: 3, + baseBackoffSec: 5, + maxBackoffSec: 180, + }; + return { + ...raw, + risk: parseRiskConfig(raw.risk), + timing: parseTimingConfig(raw.timing), + collateral: parseCollateralConfig(raw.collateral), + oracle: parseOracleConfig(raw.oracle), + venues: raw.venues.map(parseVenue), + txCoordinator: { + maxCallsPerTx: tx.maxCallsPerTx, + confirmationTimeoutMs: secondsToMs(tx.confirmationTimeoutSec, "txCoordinator.confirmationTimeoutSec"), + maxReplacements: tx.maxReplacements, + replacementFeeBumpPct: tx.replacementFeeBumpPct, + }, + circuitBreaker: { + quarantineThreshold: cb.quarantineThreshold, + baseBackoffMs: secondsToMs(cb.baseBackoffSec, "circuitBreaker.baseBackoffSec"), + maxBackoffMs: secondsToMs(cb.maxBackoffSec, "circuitBreaker.maxBackoffSec"), + }, + rollCheckIntervalMs: secondsToMs(raw.rollCheckIntervalSec, "rollCheckIntervalSec"), + sharedStalenessGraceMs: secondsToMs(raw.sharedStalenessGraceSec, "sharedStalenessGraceSec"), + }; + }, + validate: (cfg) => { + if (!cfg.wallets[cfg.wallet]) { + throw new ConfigError(`wallet "${cfg.wallet}" not in wallets map`); + } + const kinds = cfg.venues.map((v) => v.kind); + if (new Set(kinds).size !== kinds.length) { + throw new ConfigError("duplicate venue kind; declare at most one perps and one futures venue"); + } + }, + }); +} diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts new file mode 100644 index 0000000..dc6ce68 --- /dev/null +++ b/market-maker/src/apps/portfolio/main.ts @@ -0,0 +1,318 @@ +import pino from "pino"; +import { loadDotenvFiles } from "../../core/env.ts"; +import { createNetworkClients } from "../../core/client.ts"; +import { WalletRegistry } from "../../core/wallet.ts"; +import { OracleTracker } from "../../core/oracleTracker.ts"; +import { HashpriceOracleSubgraphSource } from "../../core/historicalPriceSource.ts"; +import { GasTracker } from "../../core/gasTracker.ts"; +import { InventoryManager } from "../../core/inventoryManager.ts"; +import { CollateralTracker } from "../../core/collateralTracker.ts"; +import { PortfolioCollateralAccount } from "../../core/portfolioCollateral.ts"; +import { RiskManager } from "../../core/riskManager.ts"; +import { BookTracker } from "../../core/bookTracker.ts"; +import { Quoter, type QuoterConfig } from "../../core/quoter.ts"; +import { OrderExecutor } from "../../core/orderExecutor.ts"; +import { MarketRuntime } from "../../core/marketRuntime.ts"; +import { NonceManager } from "../../core/nonceManager.ts"; +import { TxCoordinator } from "../../core/txCoordinator.ts"; +import { PortfolioHealthCheck } from "../../core/portfolioHealth.ts"; +import { runPortfolioLoop, type RollFn } from "../../core/portfolioRunner.ts"; +import { serializeError } from "../../core/errSerializer.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; +import { createPerpsVenue } from "../../adapters/perps/index.ts"; +import { FuturesVenueAdapter } from "../../adapters/futures/index.ts"; +import type { InstrumentAdapter, VenueAdapter, WalletContext } from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.ts"; +import { + loadPortfolioConfig, + type ParsedFuturesVenue, + type ParsedPerpsVenue, + type ParsedVenue, + type PortfolioMakerConfig, +} from "./config.ts"; + +/** Shared context passed to every market factory. */ +interface BuildContext { + config: PortfolioMakerConfig; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; + historyUrl?: string; +} + +function buildOracle(instrument: InstrumentAdapter, ctx: BuildContext): OracleTracker { + const history = ctx.historyUrl + ? new HashpriceOracleSubgraphSource({ url: ctx.historyUrl, logger: ctx.logger }) + : undefined; + return new OracleTracker(instrument, ctx.logger, { + windowSize: ctx.config.oracle.windowSize, + precisionBits: ctx.config.oracle.precisionBits, + historyLookbackMultiplier: ctx.config.oracle.historyLookbackMultiplier, + history, + pollIntervalMs: ctx.config.timing.pollIntervalMs, + }); +} + +function quoterPricing(venue: ParsedVenue, gasPenaltyBps: number): QuoterConfig["pricing"] { + if (venue.kind === "perps") { + const p = (venue as ParsedPerpsVenue).pricing; + return { + strategy: "effective-spread", + minSpreadBps: p.minSpreadBps, + volatilityMultiplier: p.volatilityMultiplier, + inventorySkewGamma: p.inventorySkewGamma, + gasPenaltyBps, + }; + } + const p = (venue as ParsedFuturesVenue).pricing; + return { + strategy: "reservation-price", + riskAversion: p.riskAversion, + marginCallTimeSeconds: p.marginCallTimeSec, + minSpreadBps: p.minSpreadBps, + volatilityMultiplier: p.volatilityMultiplier, + gasPenaltyBps, + }; +} + +function quoterSizing(venue: ParsedVenue): QuoterConfig["sizing"] { + if (venue.kind === "perps") { + const s = (venue as ParsedPerpsVenue).sizing; + return { + strategy: "linear", + baseQuantity: s.baseQuantity, + numLevelsPerSide: s.numLevelsPerSide, + }; + } + const s = (venue as ParsedFuturesVenue).sizing; + return { + strategy: "geometric-taper", + baseQuantity: s.baseQuantity, + numLevelsPerSide: s.numLevelsPerSide, + taperRatio: s.taperRatio, + }; +} + +/** Build a fully-wired (but not-yet-started) market for an instrument. */ +function buildMarket( + instrument: InstrumentAdapter, + venue: ParsedVenue, + ctx: BuildContext, +): MarketRuntime { + const { config, gas, risk, logger } = ctx; + const oracle = buildOracle(instrument, ctx); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: venue.maxPositionSize }, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + const quoter = new Quoter( + instrument, + { + pricing: quoterPricing(venue, config.risk.gasPenaltyBps), + sizing: quoterSizing(venue), + maxSkewTicks: venue.pricing.maxSkewTicks, + levelSpacingTicks: config.timing.levelSpacingTicks, + volHorizonSec: config.timing.pollIntervalMs / 1000, + }, + oracle, + gas, + inventory, + risk, + logger, + ); + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + requoteThresholdTicks: config.timing.requoteThresholdTicks, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + dryRun: config.dryRun, + }, + quoter, + book, + gas, + risk, + oracle, + logger, + ); + return new MarketRuntime({ + instrument, + oracle, + book, + inventory, + quoter, + executor, + breaker: config.circuitBreaker, + logger, + }); +} + +async function main(): Promise { + loadDotenvFiles(import.meta.dirname); + const config = loadPortfolioConfig(); + const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); + logger.info( + { venues: config.venues.map((v) => v.kind), dryRun: config.dryRun }, + "starting portfolio mm", + ); + + const network: NetworkClients = createNetworkClients(config.network.name, config.network.rpcUrl); + const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const wallet: WalletContext = wallets.get(config.wallet); + + // ── Venues ──────────────────────────────────────────────────────────── + const venueAdapters: VenueAdapter[] = []; + let futuresVenue: FuturesVenueAdapter | null = null; + let futuresCfg: ParsedFuturesVenue | null = null; + + for (const v of config.venues) { + if (v.kind === "perps") { + venueAdapters.push( + await createPerpsVenue({ + network, + wallet, + address: v.address, + readBatchSize: config.readBatchSize, + cancelBatchSize: config.writeBatchSize, + createBatchSize: config.writeBatchSize, + logger, + }), + ); + } else { + const fv = new FuturesVenueAdapter({ + network, + wallet, + address: v.address, + readBatchSize: config.readBatchSize, + writeBatchSize: config.writeBatchSize, + marketSelection: v.marketSelection, + logger, + }); + futuresVenue = fv; + futuresCfg = v; + venueAdapters.push(fv); + } + } + + // ── Shared portfolio layer ────────────────────────────────────────────── + const gas = new GasTracker( + network.publicClient, + { + ethPriceFeedAddress: + config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const collateralAccount = new PortfolioCollateralAccount( + venueAdapters.map((v) => v.account), + network.publicClient, + ); + const collateral = new CollateralTracker( + collateralAccount, + { + autoDeposit: config.collateral.autoDeposit, + autoDepositMinAmount: config.collateral.autoDepositMinAmount, + maxCollateralAmount: config.collateral.maxCollateralAmount, + }, + logger, + ); + const risk = new RiskManager( + { + maxPositionSize: config.risk.maxPositionSize, + maxUtilizationPct: config.risk.maxUtilizationPct, + minCollateralBalance: config.risk.minCollateralBalance, + maxDailyLossUsd: config.risk.maxDailyLossUsd, + maxGasBudgetPerHourUsd: config.risk.maxGasBudgetPerHourUsd, + maxGasBudgetPerDayUsd: config.risk.maxGasBudgetPerDayUsd, + }, + null, + collateral, + gas, + // RiskManager keeps an oracle ref for future use but never reads it; + // supply a throwaway so the portfolio (no single price source) type-checks. + undefined as unknown as OracleTracker, + logger, + ); + + const ctx: BuildContext = { config, gas, risk, logger, historyUrl: config.oracle.history?.subgraphUrl }; + + // ── Build initial market set ──────────────────────────────────────────── + const markets: MarketRuntime[] = []; + for (let i = 0; i < config.venues.length; i++) { + const vCfg = config.venues[i]; + const adapter = venueAdapters[i]; + const instruments = await adapter.listInstruments(); + for (const instrument of instruments) { + markets.push(buildMarket(instrument, vCfg, ctx)); + } + } + logger.info({ count: markets.length }, "built initial markets"); + + // ── Centralized submission ─────────────────────────────────────────────── + const nonce = new NonceManager( + network.publicClient, + wallet.walletClient, + wallet.account, + network.chain, + { + confirmationTimeoutMs: config.txCoordinator.confirmationTimeoutMs, + maxReplacements: config.txCoordinator.maxReplacements, + replacementFeeBumpPct: config.txCoordinator.replacementFeeBumpPct, + }, + logger, + ); + const coordinator = new TxCoordinator( + nonce, + { maxCallsPerTx: config.txCoordinator.maxCallsPerTx }, + logger, + ); + + const health = new PortfolioHealthCheck({ + port: config.health.port, + appName: "portfolio-mm", + configSummary: sanitiseConfig(config), + collateral, + gas, + risk, + logger, + }); + health.walletAddress = wallet.account.address; + + // ── Roll: reconcile futures expiries against the live venue selection ──── + const onRoll: RollFn | undefined = + futuresVenue && futuresCfg + ? async () => { + const { added, dropped } = await futuresVenue.resolveMarkets(); + return { + add: added.map((inst) => buildMarket(inst, futuresCfg, ctx)), + removeIds: dropped.map((inst) => inst.id), + }; + } + : undefined; + + await runPortfolioLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + rollCheckIntervalMs: config.rollCheckIntervalMs, + sharedStalenessGraceMs: config.sharedStalenessGraceMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, + dryRun: config.dryRun, + markets, + gas, + collateral, + risk, + coordinator, + health, + logger, + onRoll, + }); +} + +main(); diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index a412769..e6eaf50 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -1,6 +1,7 @@ import type { Account, Chain, + ContractFunctionParameters, PublicClient, Transport, WalletClient, @@ -137,13 +138,44 @@ export interface CollateralAccount { canPlace(additionalIM: bigint): Promise; } +/** + * A `snapshot()` decomposed into its underlying multicall reads so the + * portfolio account can batch every venue into a single RPC round trip. + * + * `shared` reads are portfolio-wide (vault balance, IM/MM, wallet token, native + * balance) and therefore **identical across venues** for a given wallet — the + * aggregator reads them once. `venue` reads are venue-specific (order margin, + * unrealized PnL). `decode` reconstructs the snapshot from the concatenated + * results in `[...shared, ...venue]` order. + */ +export interface MarginReadPlan { + shared: ContractFunctionParameters[]; + venue: ContractFunctionParameters[]; + decode(results: readonly unknown[]): CollateralSnapshot; +} + +/** + * A collateral account that can expose its reads for batched aggregation. + * Implemented by the concrete venue accounts (perps, futures); the portfolio + * aggregator uses it to fuse all venues' reads into one multicall. + */ +export interface BatchableCollateralAccount extends CollateralAccount { + buildMarginReadPlan(): Promise; +} + +export function isBatchableCollateralAccount( + account: CollateralAccount, +): account is BatchableCollateralAccount { + return ( + typeof (account as BatchableCollateralAccount).buildMarginReadPlan === "function" + ); +} + // ─── Instrument context (venue-specific hints for pricing) ────────────────── export interface InstrumentContext { /** Unix seconds of delivery / expiry, if any. */ deliveryDate?: number; - /** Contract multiplier (e.g. futures' deliveryDurationDays). */ - contractMultiplier?: bigint; /** Strike price (options). */ strike?: bigint; /** Call vs put (options). */ @@ -214,6 +246,8 @@ export type VenueEvent = side: Side; size: bigint; instrumentId?: string; + /** Futures expiry (unix seconds) the order belongs to; undefined for perps. */ + deliveryDate?: bigint; } | { type: "order-updated"; @@ -291,10 +325,10 @@ export interface InstrumentAdapter { * * Mirrors the on-chain margin computation for the venue: * - Perps: imSpotShock × notional / 1e18 - * - Futures: pricePerDay × deliveryDurationDays × marginPct / 100 + * - Futures: pricePerDay × marginPct / 100 (one unit, no duration multiplier) * * Adapter computes synchronously from already-cached state (imSpotShock, - * deliveryDurationDays). Returns 0n if it can't be estimated yet. + * marginPct). Returns 0n if it can't be estimated yet. */ estimateOrderMargin(intent: OrderIntent): bigint; @@ -327,15 +361,29 @@ export interface VenueAdapter { readonly events: VenueEvents; readonly account: CollateralAccount; - /** The MM's instrument on this venue. */ + /** + * The MM's primary instrument on this venue. For single-instrument venues + * (perps) this is the only book; for multi-instrument venues (futures across + * expiries) it is the nearest one. Kept for back-compat and single-market + * callers; prefer {@link listInstruments} for the portfolio runner. + */ getInstrument(): Promise; + /** + * All instruments this venue currently wants quoted. Perps returns a single + * element; futures returns one `InstrumentAdapter` per selected delivery + * date. The set can change over time (futures roll) — callers re-invoke to + * pick up added/dropped markets. + */ + listInstruments(): Promise; + /** * Batch cancels/creates in one tx. Returns tx hash. Implementations route - * through the venue contract's multicall function. + * through the venue contract's multicall function. `nonce` is supplied by the + * shared NonceManager when the portfolio runner sequences multi-venue txs. */ multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint }, + opts: { maxFeePerGas?: bigint; nonce?: number }, ): Promise<`0x${string}`>; } diff --git a/market-maker/src/core/circuitBreaker.ts b/market-maker/src/core/circuitBreaker.ts new file mode 100644 index 0000000..0377e07 --- /dev/null +++ b/market-maker/src/core/circuitBreaker.ts @@ -0,0 +1,63 @@ +export type BreakerState = "active" | "degraded" | "quarantined"; + +export interface CircuitBreakerConfig { + /** Consecutive errors before a market is quarantined. Default 3. */ + quarantineThreshold?: number; + /** Base backoff once quarantined (ms). Default 5s. */ + baseBackoffMs?: number; + /** Backoff ceiling (ms). Default 3min. */ + maxBackoffMs?: number; +} + +/** + * Per-market fault isolation. Tracks consecutive failures and, past a + * threshold, quarantines the market with exponential backoff so a persistently + * failing expiry stops consuming cycles while its healthy siblings keep + * quoting. A single success clears it back to `active`. + */ +export class CircuitBreaker { + state: BreakerState = "active"; + consecutiveErrors = 0; + lastError: unknown = null; + + private nextRetryAt = 0; + private readonly threshold: number; + private readonly baseBackoffMs: number; + private readonly maxBackoffMs: number; + + constructor(cfg: CircuitBreakerConfig = {}) { + this.threshold = cfg.quarantineThreshold ?? 3; + this.baseBackoffMs = cfg.baseBackoffMs ?? 5_000; + this.maxBackoffMs = cfg.maxBackoffMs ?? 180_000; + } + + recordSuccess(): void { + this.state = "active"; + this.consecutiveErrors = 0; + this.lastError = null; + this.nextRetryAt = 0; + } + + recordError(err: unknown, now: number = Date.now()): void { + this.consecutiveErrors++; + this.lastError = err; + if (this.consecutiveErrors >= this.threshold) { + this.state = "quarantined"; + this.nextRetryAt = now + this.backoff(); + } else { + this.state = "degraded"; + } + } + + /** Whether the guarded work may run this cycle. */ + canAttempt(now: number = Date.now()): boolean { + if (this.state !== "quarantined") return true; + return now >= this.nextRetryAt; + } + + private backoff(): number { + const over = this.consecutiveErrors - this.threshold; + const ms = this.baseBackoffMs * 2 ** Math.max(0, over); + return Math.min(ms, this.maxBackoffMs); + } +} diff --git a/market-maker/src/core/inventoryManager.ts b/market-maker/src/core/inventoryManager.ts index 00045dd..d499ae9 100644 --- a/market-maker/src/core/inventoryManager.ts +++ b/market-maker/src/core/inventoryManager.ts @@ -64,4 +64,9 @@ export class InventoryManager { get absPosition(): bigint { return bigAbs(this.netQuantity); } + + /** Configured position cap for this market (venue-native units). */ + get maxPositionSize(): bigint { + return this.cfg.maxPositionSize; + } } diff --git a/market-maker/src/core/marketRuntime.ts b/market-maker/src/core/marketRuntime.ts new file mode 100644 index 0000000..acaf777 --- /dev/null +++ b/market-maker/src/core/marketRuntime.ts @@ -0,0 +1,179 @@ +import type pino from "pino"; +import type Fraction from "fraction.js"; +import type { InstrumentAdapter } from "./adapter.ts"; +import type { BookTracker } from "./bookTracker.ts"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import type { Quoter } from "./quoter.ts"; +import type { OrderExecutor } from "./orderExecutor.ts"; +import type { MarketIntents } from "./txCoordinator.ts"; +import { CircuitBreaker, type CircuitBreakerConfig } from "./circuitBreaker.ts"; +import { toErrorInfo } from "./errSerializer.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export interface MarketRuntimeDeps { + instrument: InstrumentAdapter; + oracle: OracleTracker; + book: BookTracker; + inventory: InventoryManager; + quoter: Quoter; + executor: OrderExecutor; + breaker?: CircuitBreakerConfig; + logger: pino.Logger; +} + +/** + * One quoting unit (perps, or a single futures expiry) bundling its book, + * inventory, quoter, and executor behind a circuit breaker. Every on-chain + * touch is guarded so a fault in this market is recorded and skipped without + * disturbing sibling markets. Planning is decoupled from submission: `plan()` + * yields `MarketIntents` for the shared `TxCoordinator`. + */ +export class MarketRuntime { + readonly instrument: InstrumentAdapter; + readonly oracle: OracleTracker; + readonly book: BookTracker; + readonly inventory: InventoryManager; + readonly quoter: Quoter; + readonly executor: OrderExecutor; + readonly breaker: CircuitBreaker; + + private readonly logger: pino.Logger; + private initialized = false; + + constructor(deps: MarketRuntimeDeps) { + this.instrument = deps.instrument; + this.oracle = deps.oracle; + this.book = deps.book; + this.inventory = deps.inventory; + this.quoter = deps.quoter; + this.executor = deps.executor; + this.breaker = new CircuitBreaker(deps.breaker); + this.logger = deps.logger.child({ component: "market", instrument: deps.instrument.id }); + } + + get id(): string { + return this.instrument.id; + } + + /** + * Initialize this market (own-order bootstrap, book start, quoter init). + * On failure the market is quarantined but the error is swallowed so the + * process can still start with healthy markets. Returns whether init + * succeeded. + */ + async start(): Promise { + try { + await this.oracle.initialize(); + await this.instrument.ownOrders.bootstrap(); + await this.book.start(); + await this.quoter.initialize(); + this.breaker.recordSuccess(); + this.initialized = true; + this.logger.info("market initialized"); + return true; + } catch (err) { + this.breaker.recordError(err); + this.logger.error({ err }, "market init failed; quarantined"); + return false; + } + } + + /** Refresh book + inventory. Guarded by the circuit breaker. */ + async update(now: number = Date.now()): Promise { + if (!this.breaker.canAttempt(now)) return; + try { + // Late init for markets that were quarantined at startup. + if (!this.initialized) { + await this.oracle.initialize(); + await this.instrument.ownOrders.bootstrap(); + await this.book.start(); + await this.quoter.initialize(); + this.initialized = true; + } + await this.oracle.update(); + await this.book.refresh(); + await this.inventory.update(); + this.breaker.recordSuccess(); + } catch (err) { + this.breaker.recordError(err, now); + this.logger.error( + { err, state: this.breaker.state, consecutive: this.breaker.consecutiveErrors }, + "market update failed", + ); + } + } + + /** + * Compute this market's desired quotes and diff them against resting orders. + * Returns `null` when the market is quarantined, uninitialized, or no + * requote is warranted this cycle. Never throws. + */ + plan(now: number = Date.now()): MarketIntents | null { + if (!this.initialized || !this.breaker.canAttempt(now)) return null; + try { + const desired = this.quoter.computeQuotes(); + const planned = this.executor.plan(desired); + if (!planned) return null; + return { + instrument: this.instrument, + cancels: planned.cancels.map((o) => ({ orderId: o.orderId })), + creates: planned.creates, + }; + } catch (err) { + this.breaker.recordError(err, now); + this.logger.error({ err }, "market plan failed"); + return null; + } + } + + /** Bookkeeping after a successful submission for this market. */ + recordRequote(placed: number, cancelled: number): void { + this.executor.recordRequote(placed, cancelled); + } + + /** Cancel every resting order for this market (shutdown / quarantine). */ + async cancelAll(): Promise { + try { + await this.executor.cancelAll(); + } catch (err) { + this.logger.error({ err }, "cancelAll failed"); + } + } + + stop(): void { + this.book.stop(); + } + + /** Snapshot for /health. */ + healthState(): { + id: string; + breaker: string; + consecutiveErrors: number; + lastError: ErrorInfo | null; + oraclePrice: string; + volatilityPerSecond: number; + netPosition: string; + bestBid: string; + bestAsk: string; + ownOrders: number; + } { + return { + id: this.id, + breaker: this.breaker.state, + consecutiveErrors: this.breaker.consecutiveErrors, + lastError: this.breaker.lastError ? toErrorInfo(this.breaker.lastError) : null, + oraclePrice: this.oracle.currentPrice.toString(), + volatilityPerSecond: fractionToNumber(this.oracle.volatilityPerSecond), + netPosition: this.inventory.netQuantity.toString(), + bestBid: this.book.bestBid.toString(), + bestAsk: this.book.bestAsk.toString(), + ownOrders: this.book.ownOrders.size, + }; + } +} + +function fractionToNumber(value: Fraction): number { + const v = value.simplify(1e-12); + return (Number(v.s) * Number(v.n)) / Number(v.d); +} diff --git a/market-maker/src/core/math.ts b/market-maker/src/core/math.ts index bf94ade..0a43080 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -8,6 +8,10 @@ import Fraction from "fraction.js"; import { ln, sqrt } from "./rational.ts"; +// Perps quantity scale. Mirrors `HashPowerPerpsDEX.QUANTITY_DECIMALS()` (an on-chain +// `uint8 public constant`). The value is hardcoded here so the hot-path sizing/notional +// math stays synchronous, but it is the CHAIN that is authoritative: the perps venue +// asserts this matches on-chain at startup (`validateQuantityDecimals`) and aborts on drift. export const QUANTITY_DECIMALS = 6; export const QUANTITY_SCALE = 10n ** BigInt(QUANTITY_DECIMALS); export const BPS_SCALE = 10_000n; diff --git a/market-maker/src/core/nonceManager.ts b/market-maker/src/core/nonceManager.ts new file mode 100644 index 0000000..9216997 --- /dev/null +++ b/market-maker/src/core/nonceManager.ts @@ -0,0 +1,194 @@ +import type { Account, Chain, Hex, PublicClient, WalletClient } from "viem"; +import type pino from "pino"; + +export interface NonceManagerConfig { + /** How long to wait for a tx receipt before treating it as stuck. Default 60s. */ + confirmationTimeoutMs?: number; + /** Max replacement-by-fee attempts before escalating to a cancel-tx. Default 2. */ + maxReplacements?: number; + /** Fee bump per replacement attempt, in percent. Default 15%. */ + replacementFeeBumpPct?: number; +} + +/** Broadcasts one logical tx at the given nonce/fee and returns its hash. */ +export type Broadcast = (params: { + nonce: number; + maxFeePerGas: bigint; +}) => Promise; + +export interface TxOutcome { + gasUsed: bigint; + effectiveGasPrice: bigint; +} + +/** + * Owns the shared wallet's nonce for a single-wallet, multi-venue process. + * + * All submissions are **serialized** through an internal queue so nonces are + * assigned in a single deterministic order across venues — a perps tx and a + * futures tx in the same cycle get consecutive nonces and never race. + * + * Stuck-tx recovery keeps one wedged venue tx from starving the other: + * 1. Broadcast at nonce N; await the receipt with a timeout. + * 2. On timeout, resubmit the **same nonce** with a bumped fee + * (replacement-by-fee — at most one of original/replacement can land, so + * this is safe even for non-idempotent creates). + * 3. After `maxReplacements`, escalate to a `cancel-tx` (0-value self-send at + * nonce N with an aggressive fee) to free the nonce, then advance. + */ +export class NonceManager { + private next: number | null = null; + private queue: Promise = Promise.resolve(); + + private readonly confirmationTimeoutMs: number; + private readonly maxReplacements: number; + private readonly bumpPct: number; + + private readonly publicClient: PublicClient; + private readonly walletClient: WalletClient; + private readonly account: Account; + private readonly chain: Chain; + private readonly logger: pino.Logger; + + constructor( + publicClient: PublicClient, + walletClient: WalletClient, + account: Account, + chain: Chain, + cfg: NonceManagerConfig, + logger: pino.Logger, + ) { + this.publicClient = publicClient; + this.walletClient = walletClient; + this.account = account; + this.chain = chain; + this.confirmationTimeoutMs = cfg.confirmationTimeoutMs ?? 60_000; + this.maxReplacements = cfg.maxReplacements ?? 2; + this.bumpPct = cfg.replacementFeeBumpPct ?? 15; + this.logger = logger.child({ component: "nonce" }); + } + + /** + * Submit one logical tx. Resolves with the receipt's gas figures, or throws + * if the tx could not be landed even after replacement + cancel escalation. + * Serialized against every other in-flight `submit`. + */ + submit(broadcast: Broadcast, opts: { maxFeePerGas: bigint; label: string }): Promise { + return this.enqueue(() => this.submitInner(broadcast, opts)); + } + + /** Force a nonce re-read from chain on the next submit (after a desync). */ + resetNonce(): void { + this.next = null; + } + + private enqueue(fn: () => Promise): Promise { + const run = this.queue.then(fn, fn); + // Keep the chain alive regardless of individual outcomes. + this.queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async nextNonce(): Promise { + if (this.next === null) { + this.next = await this.publicClient.getTransactionCount({ + address: this.account.address, + blockTag: "pending", + }); + } + return this.next; + } + + private async submitInner( + broadcast: Broadcast, + opts: { maxFeePerGas: bigint; label: string }, + ): Promise { + const nonce = await this.nextNonce(); + let fee = opts.maxFeePerGas; + + for (let attempt = 0; attempt <= this.maxReplacements; attempt++) { + try { + const hash = await broadcast({ nonce, maxFeePerGas: fee }); + const receipt = await this.waitWithTimeout(hash); + if (receipt) { + this.next = nonce + 1; + return { + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + }; + } + // Timeout: bump fee and resubmit the same nonce. + fee = this.bump(fee); + this.logger.warn( + { label: opts.label, nonce, attempt, maxFeePerGas: fee.toString() }, + "tx confirmation timed out; replacing by fee", + ); + } catch (err) { + // A submission error (revert-on-send, RPC error). Fee-bump-and-retry a + // couple of times; a persistent failure likely means the nonce is + // wedged, so unstick it below. + this.logger.error( + { err, label: opts.label, nonce, attempt }, + "tx submission failed", + ); + if (attempt >= this.maxReplacements) { + await this.tryCancelTx(nonce, fee); + this.next = nonce + 1; + this.resetNonce(); // resync from chain next time in case of desync + throw err instanceof Error ? err : new Error(String(err)); + } + fee = this.bump(fee); + } + } + + // Exhausted replacements on repeated timeout: free the nonce and advance. + await this.tryCancelTx(nonce, fee); + this.next = nonce + 1; + throw new Error( + `tx "${opts.label}" stuck at nonce ${nonce} after ${this.maxReplacements} replacements`, + ); + } + + private bump(fee: bigint): bigint { + return (fee * BigInt(100 + this.bumpPct)) / 100n; + } + + private async waitWithTimeout( + hash: Hex, + ): Promise<{ gasUsed: bigint; effectiveGasPrice: bigint } | null> { + const timeout = new Promise((resolve) => + setTimeout(() => resolve(null), this.confirmationTimeoutMs), + ); + const receipt = this.publicClient + .waitForTransactionReceipt({ hash }) + .then((r) => ({ gasUsed: r.gasUsed, effectiveGasPrice: r.effectiveGasPrice })) + .catch(() => null); + return Promise.race([receipt, timeout]); + } + + /** + * Replace a stuck tx with a 0-value self-send at the same nonce to free it. + * Best-effort: logged and swallowed on failure (the caller advances anyway). + */ + private async tryCancelTx(nonce: number, fee: bigint): Promise { + try { + const aggressive = this.bump(fee); + const hash = await this.walletClient.sendTransaction({ + account: this.account, + chain: this.chain, + to: this.account.address, + value: 0n, + nonce, + maxFeePerGas: aggressive, + maxPriorityFeePerGas: aggressive, + }); + await this.waitWithTimeout(hash); + this.logger.warn({ nonce, hash }, "sent cancel-tx to unstick nonce"); + } catch (err) { + this.logger.error({ err, nonce }, "cancel-tx failed; will resync nonce"); + } + } +} diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 40f88f7..8b4f53e 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -70,10 +70,16 @@ export class OrderExecutor { }); } - async reconcile(desired: OrderIntent[]): Promise { - if (!this.shouldRequote(desired)) { - return; - } + /** + * Compute the diff (stale cancels + missing creates) for `desired` without + * submitting anything. Returns `null` when no requote should happen this + * cycle (cooldown, no drift, or gas-spike deferral). The portfolio runner + * feeds the returned intents to the shared `TxCoordinator`, which runs the + * aggregate pre-trade gate — so `plan()` deliberately does NOT call + * `canPlaceOrders` (that would under-count across markets). + */ + plan(desired: OrderIntent[]): { cancels: OwnOrder[]; creates: OrderIntent[] } | null { + if (!this.shouldRequote(desired)) return null; if (this.gas.isGasSpiking) { const drift = this.priceDriftTicks(); @@ -86,18 +92,38 @@ export class OrderExecutor { }, "requote skipped: gas spike, drift below urgent threshold", ); - return; + return null; } this.logger.warn({ drift }, "proceeding with requote despite gas spike"); } - const ordersToCancel = this.findStaleOrders(desired); - const ordersToPlace = this.findNewOrders(desired); - - if (ordersToCancel.length === 0 && ordersToPlace.length === 0) { + const cancels = this.findStaleOrders(desired); + const creates = this.findNewOrders(desired); + if (cancels.length === 0 && creates.length === 0) { this.logger.debug("no order changes needed"); - return; + return null; } + return { cancels, creates }; + } + + /** + * Update timing/stat bookkeeping after a submission (whether via this + * executor's own `reconcile` or the shared coordinator). Idempotent within a + * cycle; safe to call once per successful submit. + */ + recordRequote(placed: number, cancelled: number): void { + this.stats.ordersCancelled += cancelled; + this.stats.ordersPlaced += placed; + this.lastRequoteAt = Date.now(); + this.lastQuoteMidPrice = this.oracle.currentPrice; + this.stats.reconcileCount++; + } + + async reconcile(desired: OrderIntent[]): Promise { + const planned = this.plan(desired); + if (!planned) return; + const ordersToCancel = planned.cancels; + const ordersToPlace = planned.creates; // Pre-trade engine gate: ask whether the new orders' total IM still fits // the wallet's portfolio IM budget. If not, only cancel; don't add risk. diff --git a/market-maker/src/core/portfolioCollateral.ts b/market-maker/src/core/portfolioCollateral.ts new file mode 100644 index 0000000..b5daf39 --- /dev/null +++ b/market-maker/src/core/portfolioCollateral.ts @@ -0,0 +1,95 @@ +import type { PublicClient } from "viem"; +import { + type CollateralAccount, + type CollateralSnapshot, + isBatchableCollateralAccount, +} from "./adapter.ts"; + +/** + * Composes one or more venue `CollateralAccount`s into a single portfolio view + * for the shared `CollateralTracker`. + * + * Vault balance and portfolio IM/MM are per-wallet on the shared engine, so + * they are identical across venues. Venue-specific order margin and unrealized + * PnL are summed. The pre-trade gate, deposit, and IM-shock hit the shared + * vault/engine and delegate to the first account. + * + * Fast path: when every account exposes a `buildMarginReadPlan()` (the concrete + * perps/futures accounts do), all venues are fused into ONE multicall — the + * shared vault/IM/MM/wallet/native reads happen exactly once and only the + * per-venue order-margin/PnL reads scale with venue count. Falls back to + * per-account `snapshot()` (one RPC each) for any non-batchable account. + */ +export class PortfolioCollateralAccount implements CollateralAccount { + private readonly accounts: CollateralAccount[]; + private readonly publicClient: PublicClient; + + constructor(accounts: CollateralAccount[], publicClient: PublicClient) { + if (accounts.length === 0) { + throw new Error("PortfolioCollateralAccount requires at least one account"); + } + this.accounts = accounts; + this.publicClient = publicClient; + } + + async snapshot(): Promise { + if (this.accounts.every(isBatchableCollateralAccount)) { + return this.batchedSnapshot(); + } + return this.perAccountSnapshot(); + } + + /** Single multicall across all venues; shared reads counted once. */ + private async batchedSnapshot(): Promise { + const batchable = this.accounts.filter(isBatchableCollateralAccount); + const plans = await Promise.all(batchable.map((a) => a.buildMarginReadPlan())); + + // Shared reads are identical across venues (same wallet/vault/engine/token), + // so we take them from the first plan and read them just once. + const shared = plans[0].shared; + const contracts = [...shared, ...plans.flatMap((p) => p.venue)]; + const results = await this.publicClient.multicall({ allowFailure: false, contracts }); + + const sharedResults = results.slice(0, shared.length); + let offset = shared.length; + let venueOrderMargin = 0n; + let venueUnrealizedPnl = 0n; + let primary: CollateralSnapshot | null = null; + + for (const plan of plans) { + const venueResults = results.slice(offset, offset + plan.venue.length); + offset += plan.venue.length; + const snap = plan.decode([...sharedResults, ...venueResults]); + if (!primary) primary = snap; + venueOrderMargin += snap.venueOrderMargin; + venueUnrealizedPnl += snap.venueUnrealizedPnl; + } + + return { ...(primary as CollateralSnapshot), venueOrderMargin, venueUnrealizedPnl }; + } + + /** Fallback: one snapshot RPC per account. */ + private async perAccountSnapshot(): Promise { + const snaps = await Promise.all(this.accounts.map((a) => a.snapshot())); + const primary = snaps[0]; + let venueOrderMargin = 0n; + let venueUnrealizedPnl = 0n; + for (const s of snaps) { + venueOrderMargin += s.venueOrderMargin; + venueUnrealizedPnl += s.venueUnrealizedPnl; + } + return { ...primary, venueOrderMargin, venueUnrealizedPnl }; + } + + imSpotShock(): Promise { + return this.accounts[0].imSpotShock(); + } + + deposit(amount: bigint): Promise { + return this.accounts[0].deposit(amount); + } + + canPlace(additionalIM: bigint): Promise { + return this.accounts[0].canPlace(additionalIM); + } +} diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts new file mode 100644 index 0000000..ff1a7cf --- /dev/null +++ b/market-maker/src/core/portfolioHealth.ts @@ -0,0 +1,166 @@ +import { createServer } from "node:http"; +import type { Server, ServerResponse } from "node:http"; +import type pino from "pino"; +import type Fraction from "fraction.js"; +import type { CollateralTracker } from "./collateralTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { MarketRuntime } from "./marketRuntime.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export interface PortfolioHealthOptions { + port: number; + appName: string; + configSummary: Record; + collateral: CollateralTracker; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; +} + +/** + * Portfolio-aware /health endpoint. Exposes one shared collateral/gas/risk + * block plus a per-market breakdown (circuit-breaker state, last error, + * position, top of book) so ops can see partial degradation rather than an + * all-or-nothing status. + */ +export class PortfolioHealthCheck { + private server: Server | null = null; + private startedAt = Date.now(); + + tickCount = 0; + lastTickAt = 0; + walletAddress = ""; + status: "initializing" | "init-error" | "running" | "error" | "stopped" = "initializing"; + lastError: ErrorInfo | null = null; + paused = false; + + /** Live market set provider, wired by the runner. */ + markets: () => MarketRuntime[] = () => []; + onStop: (() => Promise) | null = null; + onStart: (() => Promise) | null = null; + + private readonly opts: PortfolioHealthOptions; + + constructor(opts: PortfolioHealthOptions) { + this.opts = opts; + } + + start(): Promise { + return new Promise((resolve) => { + this.startedAt = Date.now(); + this.server = createServer((req, res) => { + try { + if (req.method === "POST" && req.url === "/stop") return this.handleStop(res); + if (req.method === "POST" && req.url === "/start") return this.handleStart(res); + if (req.method === "GET" && req.url === "/health") return this.handleHealth(res); + res.writeHead(404); + res.end(); + } catch (err) { + this.opts.logger.error({ err }, "server error"); + res.writeHead(500); + res.end(); + } + }); + const { logger, port } = this.opts; + this.server.listen(port, () => { + logger.info({ url: `http://localhost:${port}/health` }, "health endpoint started"); + resolve(); + }); + }); + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (!this.server) return resolve(); + this.server.close((err) => { + this.server = null; + if (err) reject(err); + else resolve(); + }); + }); + } + + private handleHealth(res: ServerResponse): void { + const { collateral, gas, risk } = this.opts; + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + config: this.opts.configSummary, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + venueOrderMargin: collateral.venueOrderMargin.toString(), + venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), + walletTokenBalance: collateral.walletTokenBalance.toString(), + nativeBalance: collateral.nativeBalance.toString(), + utilizationPct: collateral.utilizationPct, + }, + gas: { + gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: gas.isGasSpiking, + gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), + }, + markets: this.markets().map((m) => m.healthState()), + stats: { tickCount: this.tickCount, lastTickAt: this.lastTickAt }, + }, + bigIntReplacer, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); + } + + private handleStop(res: ServerResponse): void { + if (this.paused) return this.respondOk(res); + this.paused = true; + this.status = "stopped"; + this.lastError = null; + if (!this.onStop) return this.respondOk(res); + this.onStop() + .then(() => this.respondOk(res)) + .catch((err) => { + this.opts.logger.error({ err }, "onStop callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "stop callback failed" })); + }); + } + + private handleStart(res: ServerResponse): void { + if (!this.paused) return this.respondOk(res); + this.paused = false; + this.status = "running"; + this.lastError = null; + if (!this.onStart) return this.respondOk(res); + this.onStart() + .then(() => this.respondOk(res)) + .catch((err) => { + this.opts.logger.error({ err }, "onStart callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "start callback failed" })); + }); + } + + private respondOk(res: ServerResponse): void { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, status: this.status })); + } +} + +function fractionToNumber(value: Fraction): number { + const v = value.simplify(1e-12); + return (Number(v.s) * Number(v.n)) / Number(v.d); +} + +function bigIntReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/market-maker/src/core/portfolioRunner.ts b/market-maker/src/core/portfolioRunner.ts new file mode 100644 index 0000000..63fc81c --- /dev/null +++ b/market-maker/src/core/portfolioRunner.ts @@ -0,0 +1,363 @@ +import type pino from "pino"; +import type { GasTracker } from "./gasTracker.ts"; +import type { CollateralTracker } from "./collateralTracker.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { MarketRuntime } from "./marketRuntime.ts"; +import type { MarketIntents, TxCoordinator } from "./txCoordinator.ts"; +import type { PortfolioHealthCheck } from "./portfolioHealth.ts"; +import type { ErrorInfo } from "./errors.ts"; +import { toErrorInfo } from "./errSerializer.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const BASE_ERROR_DELAY_MS = 5_000; +const MAX_ERROR_DELAY_MS = 3 * 60_000; + +/** + * Reconciles the live market set with the venues' current selection. Called + * periodically for the futures roll. Returns markets to add (already built, + * not yet started) and ids to remove (matured / rolled off). + */ +export type RollFn = ( + current: MarketRuntime[], +) => Promise<{ add: MarketRuntime[]; removeIds: string[] }>; + +export interface PortfolioRunnerOpts { + pollIntervalMs: number; + /** How often to re-check the venue market set for the roll. */ + rollCheckIntervalMs: number; + cancelOrdersOnShutdown?: boolean; + /** + * Grace window: if shared inputs (gas/collateral) can't be refreshed for + * longer than this, stop placing new orders (existing orders are left in + * place). Default 30s. + */ + sharedStalenessGraceMs?: number; + dryRun: boolean; + + markets: MarketRuntime[]; + gas: GasTracker; + collateral: CollateralTracker; + risk: RiskManager; + coordinator: TxCoordinator; + health: PortfolioHealthCheck; + logger: pino.Logger; + + onRoll?: RollFn; +} + +/** Shared dependencies one tick reads/acts on. */ +export interface PortfolioTickDeps { + gas: GasTracker; + collateral: CollateralTracker; + risk: RiskManager; + coordinator: TxCoordinator; + health: PortfolioHealthCheck; + logger: pino.Logger; + dryRun: boolean; + /** Shared-input staleness grace (ms) before pausing new placements. */ + graceMs: number; + rollCheckIntervalMs: number; + onRoll?: RollFn; +} + +/** Loop-carried state threaded through successive ticks. */ +export interface PortfolioTickState { + markets: MarketRuntime[]; + lastSharedOkAt: number; + pauseNew: boolean; + lastRollAt: number; +} + +export interface PortfolioTickResult { + state: PortfolioTickState; + /** True when a confirmed portfolio breach forced a full cancel this tick. */ + halted: boolean; +} + +/** + * One iteration of the portfolio loop, extracted so the resilience logic is + * unit-testable without process signals or an infinite loop. Mutates the + * shared trackers/health as needed and returns the next loop-carried state. + * + * Staging (each stage's failure is contained to its own blast radius): + * 1. roll — reconcile the futures market set (add/drop expiries). + * 2. shared inputs (gas/collateral) — on failure past `graceMs`, set + * `pauseNew` (stop placing; keep existing orders). Never throws. + * 3. per-market update — each behind its own circuit breaker. + * 4. risk gate — only on FRESH shared data; a confirmed breach cancels all + * and returns `halted`. Stale data only pauses new placements. + * 5. plan + submit via the coordinator (aggregate gate + per-venue isolation). + */ +export async function runPortfolioTick( + now: number, + deps: PortfolioTickDeps, + prev: PortfolioTickState, +): Promise { + const { gas, collateral, risk, coordinator, health, logger, dryRun, graceMs } = deps; + const state: PortfolioTickState = { ...prev }; + // The single error surfaced by THIS tick (shared-input or submit). A tick + // that ends with this null and fresh inputs is healthy and clears /health. + let tickError: ErrorInfo | null = null; + + // Stage 1: roll. + if (deps.onRoll && now - state.lastRollAt > deps.rollCheckIntervalMs) { + state.lastRollAt = now; + state.markets = await applyRoll(state.markets, deps.onRoll, logger); + } + + // Stage 2: shared inputs (gas + collateral; oracles are per-market). + let sharedOk = true; + try { + await gas.update(); + await collateral.update(); + state.lastSharedOkAt = now; + state.pauseNew = false; + try { + await collateral.maybeTopUp(); + } catch (err) { + logger.error({ err }, "collateral top-up failed"); + } + } catch (err) { + sharedOk = false; + tickError = toErrorInfo(err); + logger.error({ err }, "shared input update failed"); + if (now - state.lastSharedOkAt > graceMs && !state.pauseNew) { + state.pauseNew = true; + logger.warn( + { staleMs: now - state.lastSharedOkAt }, + "shared inputs stale past grace; pausing new placements (existing orders kept)", + ); + } + } + + // Stage 3: per-market update (each isolated by its circuit breaker). + for (const m of state.markets) await m.update(now); + + // Stage 4: portfolio risk gate — only act on fresh data. A confirmed breach + // cancels everything; stale data only pauses new placements. + if (sharedOk) { + const ok = risk.check(); + if (!ok) { + health.status = "error"; + health.lastError = risk.haltReason; + await Promise.all(state.markets.map((m) => m.cancelAll())).catch((err) => + logger.error({ err }, "cancelAll after halt failed"), + ); + return { state, halted: true }; + } + } + + // Stage 5: plan per market, then submit via the coordinator. + const intents: MarketIntents[] = []; + for (const m of state.markets) { + const p = m.plan(now); + if (p) intents.push(state.pauseNew ? { ...p, creates: [] } : p); + } + const active = intents.filter((i) => i.cancels.length > 0 || i.creates.length > 0); + + if (active.length > 0) { + const res = await coordinator.submit(active, { + maxFeePerGas: gas.cappedGasPrice(), + dryRun, + canPlace: (im) => collateral.canPlace(im), + }); + for (const receipt of res.receipts) { + risk.recordGasCost(gasCostUsd(receipt, gas.ethPriceUsd)); + } + // Per-market timing bookkeeping (best-effort; failed venues re-plan next + // tick via the on-chain-diff resync). + for (const i of active) { + const m = state.markets.find((mk) => mk.instrument === i.instrument); + m?.recordRequote(i.creates.length, i.cancels.length); + } + if (res.errors.length > 0) tickError = toErrorInfo(res.errors[0]); + } + + health.status = "running"; + // A tick that saw an error (stale shared inputs or a submit revert) surfaces + // it; a fully clean tick with fresh inputs clears any stale error so /health + // recovers even during continuous active quoting. + if (tickError) health.lastError = tickError; + else if (sharedOk) health.lastError = null; + return { state, halted: false }; +} + +/** + * Single-process portfolio loop over N markets across venues. + * + * Staged so a fault's blast radius matches its domain: + * - shared-update stage (gas/collateral): failure → fail-safe pause of new + * placements past a grace window; existing orders untouched. + * - per-market stage: each market updates its own oracle/book/inventory and + * plans behind its own circuit breaker; one market's failure never stops + * the others. + * - submit stage: intents handed to the TxCoordinator, which isolates venues + * and runs the single aggregate pre-trade gate. + * The loop itself never dies — errors log, back off, and retry. + */ +export async function runPortfolioLoop(opts: PortfolioRunnerOpts): Promise { + const { + pollIntervalMs, + rollCheckIntervalMs, + dryRun, + gas, + collateral, + risk, + coordinator, + health, + logger, + onRoll, + } = opts; + const cancelOrdersOnShutdown = opts.cancelOrdersOnShutdown ?? true; + const graceMs = opts.sharedStalenessGraceMs ?? 30_000; + let markets = [...opts.markets]; + + health.markets = () => markets; + health.onStop = async () => { + logger.info("stop requested via API, cancelling orders"); + await Promise.all(markets.map((m) => m.cancelAll())); + for (const m of markets) m.stop(); + }; + health.onStart = async () => { + logger.info("start requested via API, re-initializing markets"); + await Promise.all(markets.map((m) => m.start())); + }; + + await health.start(); + + // ── Bootstrap: shared trackers (retry) + each market (isolated) ────────── + for (let attempt = 1; ; attempt++) { + try { + await gas.update(); + await collateral.update(); + risk.initialize(); + break; + } catch (err) { + health.status = "init-error"; + health.lastError = toErrorInfo(err); + const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); + logger.warn({ err, attempt, retryInMs: delay }, "shared init failed, retrying"); + await sleep(delay); + } + } + // Markets init independently — a bad expiry is quarantined, others proceed. + await Promise.all(markets.map((m) => m.start())); + health.status = "running"; + health.lastError = null; + logger.info({ markets: markets.map((m) => m.id) }, "portfolio init complete"); + + // ── Shutdown ───────────────────────────────────────────────────────────── + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ cancelOrdersOnShutdown }, "shutting down…"); + if (cancelOrdersOnShutdown) { + await Promise.all(markets.map((m) => m.cancelAll())).catch((err) => + logger.error({ err }, "failed to cancel orders during shutdown"), + ); + } + for (const m of markets) m.stop(); + await health.stop(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + // ── Main loop ────────────────────────────────────────────────────────── + const tickDeps: PortfolioTickDeps = { + gas, + collateral, + risk, + coordinator, + health, + logger, + dryRun, + graceMs, + rollCheckIntervalMs, + onRoll, + }; + let consecutiveErrors = 0; + let state: PortfolioTickState = { + markets, + lastSharedOkAt: Date.now(), + pauseNew: false, + lastRollAt: 0, + }; + + while (!shuttingDown) { + if (health.paused) { + await sleep(pollIntervalMs); + continue; + } + + try { + const result = await runPortfolioTick(Date.now(), tickDeps, state); + state = result.state; + markets = state.markets; // keep health.markets() closure in sync + consecutiveErrors = result.halted ? consecutiveErrors + 1 : 0; + } catch (err) { + consecutiveErrors++; + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "tick error"); + } + + await afterTick(health, consecutiveErrors, pollIntervalMs); + } +} + +async function afterTick( + health: PortfolioHealthCheck, + consecutiveErrors: number, + pollIntervalMs = 0, +): Promise { + health.tickCount++; + health.lastTickAt = Date.now(); + const delay = + consecutiveErrors > 0 + ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) + : pollIntervalMs; + if (delay > 0) await sleep(delay); +} + +/** Apply a roll: start added markets, cancel+stop removed ones, splice the set. */ +export async function applyRoll( + current: MarketRuntime[], + onRoll: RollFn, + logger: pino.Logger, +): Promise { + let next = current; + try { + const { add, removeIds } = await onRoll(current); + if (add.length === 0 && removeIds.length === 0) return current; + + const removeSet = new Set(removeIds); + const removed = current.filter((m) => removeSet.has(m.id)); + await Promise.all( + removed.map(async (m) => { + await m.cancelAll(); + m.stop(); + }), + ); + await Promise.all(add.map((m) => m.start())); + + next = current.filter((m) => !removeSet.has(m.id)).concat(add); + logger.info( + { added: add.map((m) => m.id), removed: [...removeSet] }, + "market set rolled", + ); + } catch (err) { + logger.error({ err }, "roll failed; keeping current market set"); + } + return next; +} + +export function gasCostUsd( + receipt: { gasUsed: bigint; effectiveGasPrice: bigint }, + ethPriceUsd: bigint, +): bigint { + if (ethPriceUsd === 0n) return 0n; + return (receipt.gasUsed * receipt.effectiveGasPrice * ethPriceUsd) / 10n ** 18n; +} diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index a9f76e9..e5fb467 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -130,7 +130,10 @@ export class Quoter { }); const { bidMid, askMid, spreadBps } = midQuote; - const { quoteBid, quoteAsk } = this.risk.allowedSides(); + const { quoteBid, quoteAsk } = this.risk.allowedSides( + this.inventory, + this.inventory.maxPositionSize, + ); const intents: OrderIntent[] = []; const spacing = BigInt(this.cfg.levelSpacingTicks) * this.tick; diff --git a/market-maker/src/core/riskManager.ts b/market-maker/src/core/riskManager.ts index 0210b9f..6c06be2 100644 --- a/market-maker/src/core/riskManager.ts +++ b/market-maker/src/core/riskManager.ts @@ -73,7 +73,12 @@ export class RiskManager { private startOfDayTimestamp = 0; private readonly cfg: RiskManagerConfig; - private readonly inventory: InventoryManager; + /** + * Default inventory for single-market callers. Null in the portfolio process, + * where `allowedSides` is always called with the per-market inventory since + * position units differ across venues (perps hashrate vs futures contracts). + */ + private readonly inventory: InventoryManager | null; private readonly collateral: CollateralTracker; private readonly gas: GasTracker; private readonly oracle: OracleTracker; @@ -81,7 +86,7 @@ export class RiskManager { constructor( cfg: RiskManagerConfig, - inventory: InventoryManager, + inventory: InventoryManager | null, collateral: CollateralTracker, gas: GasTracker, oracle: OracleTracker, @@ -186,12 +191,19 @@ export class RiskManager { } /** - * Sides allowed to quote. Respects position cap and stops quoting at high - * utilization (only the side that reduces exposure is allowed). + * Sides allowed to quote for a market. Utilization is portfolio-wide (shared + * collateral), while the direction and the position cap are per-market: + * pass the market's inventory + cap. Single-market callers may omit both to + * fall back to the injected defaults. */ - allowedSides(): { quoteBid: boolean; quoteAsk: boolean } { - const maxPos = this.cfg.maxPositionSize; - const net = this.inventory.netQuantity; + allowedSides( + inventory?: InventoryManager, + maxPositionSize?: bigint, + ): { quoteBid: boolean; quoteAsk: boolean } { + const inv = inventory ?? this.inventory; + if (!inv) return { quoteBid: false, quoteAsk: false }; + const maxPos = maxPositionSize ?? this.cfg.maxPositionSize; + const net = inv.netQuantity; if (this.collateral.utilizationPct > this.cfg.maxUtilizationPct) { if (net > 0n) return { quoteBid: false, quoteAsk: true }; diff --git a/market-maker/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts new file mode 100644 index 0000000..89e747f --- /dev/null +++ b/market-maker/src/core/txCoordinator.ts @@ -0,0 +1,168 @@ +import type pino from "pino"; +import type { + CancelIntent, + InstrumentAdapter, + OrderIntent, + VenueAdapter, +} from "./adapter.ts"; +import type { NonceManager, TxOutcome } from "./nonceManager.ts"; + +/** One market's desired order changes for a cycle. */ +export interface MarketIntents { + instrument: InstrumentAdapter; + cancels: CancelIntent[]; + creates: OrderIntent[]; +} + +export interface TxCoordinatorConfig { + /** Max encoded calls per on-chain tx before chunking. Default 50. */ + maxCallsPerTx?: number; +} + +export interface SubmitOptions { + maxFeePerGas: bigint; + dryRun: boolean; + /** + * Portfolio pre-trade gate. Returns whether `additionalIM` (summed across + * every market's creates) still fits under the wallet's IM budget. This is + * the single aggregate `engine.canPlaceOrder` check — never per market. + */ + canPlace: (additionalIM: bigint) => Promise; +} + +export interface SubmitResult { + receipts: TxOutcome[]; + /** Non-fatal per-venue errors; other venues still submitted. */ + errors: Error[]; + ordersPlaced: number; + ordersCancelled: number; + /** True if the aggregate gate denied placements (creates were dropped). */ + gateDenied: boolean; +} + +/** + * Centralized ordered submission for the single-wallet portfolio process. + * + * Responsibilities: + * 1. Aggregate pre-trade gate over ALL markets' creates (one canPlaceOrder). + * 2. Group intents by venue — expiries on the same Futures contract merge + * into one `Futures.multicall`; perps is its own contract (≥2 txs total). + * 3. Cancels-before-creates within each venue batch (free margin first). + * 4. Submit each venue independently via the shared NonceManager: a revert or + * timeout on one venue is recorded and never blocks the other. + */ +export class TxCoordinator { + private readonly nonce: NonceManager; + private readonly maxCallsPerTx: number; + private readonly logger: pino.Logger; + + constructor(nonce: NonceManager, cfg: TxCoordinatorConfig, logger: pino.Logger) { + this.nonce = nonce; + this.maxCallsPerTx = cfg.maxCallsPerTx ?? 50; + this.logger = logger.child({ component: "tx-coordinator" }); + } + + async submit(all: MarketIntents[], opts: SubmitOptions): Promise { + const result: SubmitResult = { + receipts: [], + errors: [], + ordersPlaced: 0, + ordersCancelled: 0, + gateDenied: false, + }; + + // 1. Aggregate pre-trade gate across every market's creates. + let additionalIM = 0n; + let totalCreates = 0; + for (const m of all) { + totalCreates += m.creates.length; + for (const c of m.creates) additionalIM += m.instrument.estimateOrderMargin(c); + } + let allowCreates = true; + if (totalCreates > 0 && additionalIM > 0n) { + allowCreates = await opts.canPlace(additionalIM); + if (!allowCreates) { + result.gateDenied = true; + this.logger.warn( + { additionalIM: additionalIM.toString(), wouldPlace: totalCreates }, + "aggregate canPlaceOrder denied; cancelling stale only", + ); + } + } + + // 2. Group by venue (identity). Expiries share their futures venue. + const byVenue = new Map(); + for (const m of all) { + const venue = m.instrument.venue; + const list = byVenue.get(venue); + if (list) list.push(m); + else byVenue.set(venue, [m]); + } + + // 3. Build + submit per venue, isolated. + for (const [venue, markets] of byVenue) { + const calls: `0x${string}`[] = []; + let cancelCount = 0; + let placeCount = 0; + + // Cancels first (all markets), then creates (all markets). + for (const m of markets) { + for (const c of m.cancels) { + calls.push(m.instrument.encodeCancel(c)); + cancelCount++; + } + } + if (allowCreates) { + for (const m of markets) { + for (const c of m.creates) { + calls.push(m.instrument.encodeCreate(c)); + placeCount++; + } + } + } + + if (calls.length === 0) continue; + + if (opts.dryRun) { + this.logger.info( + { venue: venue.kind, cancels: cancelCount, creates: placeCount, calls: calls.length }, + "DRY RUN: would submit venue batch", + ); + result.ordersCancelled += cancelCount; + result.ordersPlaced += placeCount; + continue; + } + + try { + const chunks = this.chunk(calls); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const outcome = await this.nonce.submit( + ({ nonce, maxFeePerGas }) => venue.multicall(chunk, { maxFeePerGas, nonce }), + { maxFeePerGas: opts.maxFeePerGas, label: `${venue.kind}#${i}` }, + ); + result.receipts.push(outcome); + } + result.ordersCancelled += cancelCount; + result.ordersPlaced += placeCount; + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + result.errors.push(wrapped); + this.logger.error( + { err: wrapped, venue: venue.kind }, + "venue submission failed — other venues unaffected", + ); + } + } + + return result; + } + + private chunk(calls: `0x${string}`[]): `0x${string}`[][] { + const out: `0x${string}`[][] = []; + for (let i = 0; i < calls.length; i += this.maxCallsPerTx) { + out.push(calls.slice(i, i + this.maxCallsPerTx)); + } + return out; + } +} diff --git a/market-maker/tests/apps/portfolio/config.test.ts b/market-maker/tests/apps/portfolio/config.test.ts new file mode 100644 index 0000000..f0f58bc --- /dev/null +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -0,0 +1,159 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadPortfolioConfig, type ParsedFuturesVenue } from "../../../src/apps/portfolio/config.ts"; + +function writeTmp(dir: string, name: string, content: string): string { + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +const VALID_YAML = ` +wallet: default +wallets: + default: + privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +network: + name: arbitrum + rpcUrl: "https://arb1.arbitrum.io/rpc" +venues: + - kind: perps + address: "0x1111111111111111111111111111111111111111" + maxPositionSize: 10 + pricing: + strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + sizing: + strategy: linear + baseQuantity: "500000000" + numLevelsPerSide: 4 + - kind: futures + address: "0x2222222222222222222222222222222222222222" + maxPositionSize: 5 + marketSelection: + mode: nearest + count: 3 + pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + sizing: + strategy: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6 +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 +gas: + gasCapMultiplier: 2.0 +timing: {} +collateral: {} +oracle: {} +health: + port: 8080 +`; + +let tmpDir: string; +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mm-pf-cfg-")); +}); + +describe("loadPortfolioConfig", () => { + it("parses a valid multi-venue config", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadPortfolioConfig({ path }); + assert.equal(cfg.wallet, "default"); + assert.equal(cfg.venues.length, 2); + assert.equal(cfg.venues[0].kind, "perps"); + assert.equal(cfg.venues[1].kind, "futures"); + // USD parsed to 6-decimal bigint. + assert.equal(cfg.venues[0].maxPositionSize, 10_000_000n); + // Futures market selection + baseQuantity bigint. + const fut = cfg.venues[1] as ParsedFuturesVenue; + assert.deepEqual(fut.marketSelection, { mode: "nearest", count: 3 }); + assert.equal(fut.sizing.baseQuantity, 500_000_000n); + }); + + it("defaults futures marketSelection to nearest-1 when omitted", () => { + const yaml = VALID_YAML.replace( + ` marketSelection: + mode: nearest + count: 3 +`, + "", + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadPortfolioConfig({ path }); + const fut = cfg.venues[1] as ParsedFuturesVenue; + assert.deepEqual(fut.marketSelection, { mode: "nearest", count: 1 }); + }); + + it("applies txCoordinator and circuitBreaker defaults", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadPortfolioConfig({ path }); + assert.equal(cfg.txCoordinator.maxCallsPerTx, 50); + assert.equal(cfg.txCoordinator.confirmationTimeoutMs, 60_000); + assert.equal(cfg.circuitBreaker.quarantineThreshold, 3); + assert.equal(cfg.rollCheckIntervalMs, 300_000); + assert.equal(cfg.sharedStalenessGraceMs, 30_000); + }); + + it("throws when the shared wallet is not declared", () => { + const yaml = VALID_YAML.replace("wallet: default", "wallet: ghost"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /ghost/); + }); + + it("rejects duplicate venue kinds", () => { + const yaml = VALID_YAML.replace('kind: futures\n address: "0x2222222222222222222222222222222222222222"', 'kind: perps\n address: "0x2222222222222222222222222222222222222222"') + .replace( + ` marketSelection: + mode: nearest + count: 3 +`, + "", + ) + .replace( + ` strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0`, + ` strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20`, + ) + .replace( + ` strategy: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6`, + ` strategy: linear + baseQuantity: "500000000" + numLevelsPerSide: 4`, + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /duplicate venue kind/); + }); + + it("rejects an empty venues array", () => { + const yaml = VALID_YAML.replace(/venues:[\s\S]*?risk:/, "venues: []\nrisk:"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /Config validation failed/); + }); +}); diff --git a/market-maker/tests/core/circuitBreaker.test.ts b/market-maker/tests/core/circuitBreaker.test.ts new file mode 100644 index 0000000..529a8e4 --- /dev/null +++ b/market-maker/tests/core/circuitBreaker.test.ts @@ -0,0 +1,55 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { CircuitBreaker } from "../../src/core/circuitBreaker.ts"; + +describe("CircuitBreaker", () => { + it("starts active and stays active below the threshold", () => { + const cb = new CircuitBreaker({ quarantineThreshold: 3 }); + assert.equal(cb.state, "active"); + cb.recordError(new Error("x")); + assert.equal(cb.state, "degraded"); + assert.equal(cb.consecutiveErrors, 1); + assert.equal(cb.canAttempt(), true); + }); + + it("quarantines at the threshold and blocks until backoff elapses", () => { + const now = 1_000_000; + const cb = new CircuitBreaker({ quarantineThreshold: 3, baseBackoffMs: 5_000 }); + cb.recordError(new Error("a"), now); + cb.recordError(new Error("b"), now); + cb.recordError(new Error("c"), now); + assert.equal(cb.state, "quarantined"); + assert.equal(cb.canAttempt(now), false); + assert.equal(cb.canAttempt(now + 4_999), false); + assert.equal(cb.canAttempt(now + 5_000), true); + }); + + it("applies exponential backoff capped at maxBackoffMs", () => { + const now = 0; + const cb = new CircuitBreaker({ + quarantineThreshold: 1, + baseBackoffMs: 1_000, + maxBackoffMs: 4_000, + }); + cb.recordError(new Error("1"), now); // over=0 -> 1000ms + assert.equal(cb.canAttempt(now + 999), false); + assert.equal(cb.canAttempt(now + 1_000), true); + cb.recordError(new Error("2"), now); // over=1 -> 2000ms + assert.equal(cb.canAttempt(now + 2_000), true); + cb.recordError(new Error("3"), now); // over=2 -> 4000ms + cb.recordError(new Error("4"), now); // over=3 -> 8000ms, capped to 4000ms + assert.equal(cb.canAttempt(now + 4_000), true); + }); + + it("recovers to active on a single success", () => { + const cb = new CircuitBreaker({ quarantineThreshold: 2 }); + cb.recordError(new Error("a")); + cb.recordError(new Error("b")); + assert.equal(cb.state, "quarantined"); + cb.recordSuccess(); + assert.equal(cb.state, "active"); + assert.equal(cb.consecutiveErrors, 0); + assert.equal(cb.lastError, null); + assert.equal(cb.canAttempt(), true); + }); +}); diff --git a/market-maker/tests/core/config/base.test.ts b/market-maker/tests/core/config/base.test.ts new file mode 100644 index 0000000..b63c0d9 --- /dev/null +++ b/market-maker/tests/core/config/base.test.ts @@ -0,0 +1,90 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Hex } from "viem"; +import { + configBigint, + expandEnv, + sanitiseConfig, +} from "../../../src/core/config/base.ts"; + +// Anvil account #0 — deterministic, not a real secret. +const TEST_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const TEST_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +describe("sanitiseConfig", () => { + it("redacts private keys, derives addresses, and masks RPC secrets", () => { + const cfg = { + wallets: { maker: { privateKey: TEST_KEY } }, + network: { rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/SUPER_SECRET?k=1" }, + }; + const s = sanitiseConfig(cfg); + const wallets = s.wallets as Record; + + assert.equal(wallets.maker.privateKey, "[REDACTED]"); + assert.equal(wallets.maker.address, TEST_ADDRESS); + assert.equal( + (s.network as { rpcUrl: string }).rpcUrl, + "https://eth-mainnet.g.alchemy.com/[redacted]", + ); + // The original config is not mutated (deep clone). + assert.equal(cfg.wallets.maker.privateKey, TEST_KEY); + assert.equal(cfg.network.rpcUrl, "https://eth-mainnet.g.alchemy.com/v2/SUPER_SECRET?k=1"); + }); + + it("marks an unparseable private key as [invalid] and leaves a bare host untouched", () => { + const s = sanitiseConfig({ + wallets: { bad: { privateKey: "0xdeadbeef" as Hex } }, + network: { rpcUrl: "http://localhost:8545" }, + }); + const wallets = s.wallets as Record; + assert.equal(wallets.bad.address, "[invalid]"); + // No path or query → host preserved, nothing to redact. + assert.equal((s.network as { rpcUrl: string }).rpcUrl, "http://localhost:8545"); + }); + + it("reports an unparseable RPC URL as [invalid url]", () => { + const s = sanitiseConfig({ + wallets: {}, + network: { rpcUrl: "not a url" }, + }); + assert.equal((s.network as { rpcUrl: string }).rpcUrl, "[invalid url]"); + }); +}); + +describe("configBigint", () => { + it("parses a numeric string", () => { + assert.equal(configBigint("1500000", "risk.maxPositionSize"), 1_500_000n); + }); + + it("throws a ConfigError with the field name on a bad value", () => { + assert.throws( + () => configBigint("12.5", "risk.cap"), + /Invalid bigint value for risk\.cap/, + ); + }); +}); + +describe("expandEnv", () => { + const env = { FOO: "bar", EMPTY: "" } as unknown as NodeJS.ProcessEnv; + + it("interpolates variables recursively through objects and arrays", () => { + const out = expandEnv({ a: "${FOO}", b: ["x", "${FOO}"], c: 3 }, env); + assert.deepEqual(out, { a: "bar", b: ["x", "bar"], c: 3 }); + }); + + it("uses the :- default when the variable is unset or empty", () => { + assert.equal(expandEnv("${MISSING:-fallback}", env), "fallback"); + assert.equal(expandEnv("${EMPTY:-fallback}", env), "fallback"); + }); + + it("throws when a required variable is unset and has no default", () => { + assert.throws(() => expandEnv("${MISSING}", env), /Environment variable "MISSING" is not set/); + }); + + it("passes through non-string leaves untouched", () => { + assert.equal(expandEnv(42, env), 42); + assert.equal(expandEnv(true, env), true); + assert.equal(expandEnv(null, env), null); + }); +}); diff --git a/market-maker/tests/core/gasTracker.test.ts b/market-maker/tests/core/gasTracker.test.ts index a8736c3..a2431bf 100644 --- a/market-maker/tests/core/gasTracker.test.ts +++ b/market-maker/tests/core/gasTracker.test.ts @@ -113,6 +113,81 @@ describe("GasTracker cost calculations", () => { }); }); +describe("GasTracker ETH price feed", () => { + function feedClient(opts: { + answer: bigint; + decimals: number; + multicallThrows?: boolean; + }): PublicClient { + return { + getGasPrice: async () => 1_000_000_000n, + multicall: async () => { + if (opts.multicallThrows) throw new Error("feed down"); + return [[0n, opts.answer, 0n, 0n, 0n], opts.decimals]; + }, + } as unknown as PublicClient; + } + + const feedCfg = makeConfig({ + ethPriceFeedAddress: "0x0000000000000000000000000000000000000fee", + }); + + it("scales an 8-decimal feed answer down to 6-decimal USDC terms", async () => { + const tracker = new GasTracker( + feedClient({ answer: 2000_00000000n, decimals: 8 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 2000_000000n); // $2000 at 6 dp + }); + + it("scales a low-decimal feed answer up to 6-decimal USDC terms", async () => { + const tracker = new GasTracker( + feedClient({ answer: 2000_00n, decimals: 2 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 2000_000000n); + }); + + it("ignores a non-positive feed answer", async () => { + const tracker = new GasTracker( + feedClient({ answer: 0n, decimals: 8 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 0n); + }); + + it("swallows a feed read failure and leaves ethPriceUsd untouched", async () => { + const tracker = new GasTracker( + feedClient({ answer: 0n, decimals: 8, multicallThrows: true }), + feedCfg, + makeLogger(), + ); + await assert.doesNotReject(tracker.update()); + assert.equal(tracker.ethPriceUsd, 0n); + }); + + it("skips the feed entirely when no address is configured", async () => { + let called = false; + const client = { + getGasPrice: async () => 1_000_000_000n, + multicall: async () => { + called = true; + return []; + }, + } as unknown as PublicClient; + const tracker = new GasTracker(client, makeConfig(), makeLogger()); + await tracker.update(); + assert.equal(called, false); + assert.equal(tracker.ethPriceUsd, 0n); + }); +}); + describe("GasTracker.cappedGasPrice", () => { it("uses cap when current < cap (median * multiplier)", () => { const tracker = new GasTracker( diff --git a/market-maker/tests/core/marketRuntime.test.ts b/market-maker/tests/core/marketRuntime.test.ts new file mode 100644 index 0000000..d623f08 --- /dev/null +++ b/market-maker/tests/core/marketRuntime.test.ts @@ -0,0 +1,193 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +import { MarketRuntime, type MarketRuntimeDeps } from "../../src/core/marketRuntime.ts"; +import type { InstrumentAdapter } from "../../src/core/adapter.ts"; +import type { OracleTracker } from "../../src/core/oracleTracker.ts"; +import type { BookTracker } from "../../src/core/bookTracker.ts"; +import type { InventoryManager } from "../../src/core/inventoryManager.ts"; +import type { Quoter } from "../../src/core/quoter.ts"; +import type { OrderExecutor } from "../../src/core/orderExecutor.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +interface Knobs { + refreshFails: boolean; + plan: { cancels: { orderId: `0x${string}` }[]; creates: unknown[] } | null; +} + +function makeDeps(knobs: Knobs): { deps: MarketRuntimeDeps; knobs: Knobs; recorded: number[] } { + const recorded: number[] = []; + const instrument = { + id: "futures@1700000000", + ownOrders: { bootstrap: async () => {} }, + } as unknown as InstrumentAdapter; + const oracle = { + initialize: async () => {}, + update: async () => {}, + currentPrice: 100n, + volatilityPerSecond: new Fraction(0n), + } as unknown as OracleTracker; + const book = { + start: async () => {}, + refresh: async () => { + if (knobs.refreshFails) throw new Error("rpc down"); + }, + stop: noop, + bestBid: 99n, + bestAsk: 101n, + ownOrders: new Map(), + } as unknown as BookTracker; + const inventory = { update: async () => {}, netQuantity: 0n } as unknown as InventoryManager; + const quoter = { + initialize: async () => {}, + computeQuotes: () => [], + } as unknown as Quoter; + const executor = { + plan: () => knobs.plan, + recordRequote: (p: number, _c: number) => recorded.push(p), + cancelAll: async () => {}, + } as unknown as OrderExecutor; + return { + deps: { + instrument, + oracle, + book, + inventory, + quoter, + executor, + breaker: { quarantineThreshold: 2, baseBackoffMs: 1_000 }, + logger: makeLogger(), + }, + knobs, + recorded, + }; +} + +describe("MarketRuntime", () => { + it("starts healthy and reports an active breaker", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + const m = new MarketRuntime(deps); + assert.equal(await m.start(), true); + assert.equal(m.breaker.state, "active"); + assert.equal(m.healthState().breaker, "active"); + assert.equal(m.healthState().id, "futures@1700000000"); + }); + + it("quarantines after repeated update failures and skips planning", async () => { + const { deps, knobs } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + const m = new MarketRuntime(deps); + await m.start(); + + knobs.refreshFails = true; + const now = 10_000; + await m.update(now); + assert.equal(m.breaker.state, "degraded"); + await m.update(now); + assert.equal(m.breaker.state, "quarantined"); + + // Quarantined → plan is skipped even though a diff exists. + assert.equal(m.plan(now), null); + // ...and update is a no-op until backoff elapses. + assert.equal(m.breaker.canAttempt(now + 500), false); + assert.equal(m.breaker.canAttempt(now + 1_000), true); + }); + + it("recovers to active after a successful update", async () => { + const { deps, knobs } = makeDeps({ refreshFails: true, plan: null }); + const m = new MarketRuntime(deps); + await m.start(); + await m.update(0); + assert.equal(m.breaker.state, "degraded"); + knobs.refreshFails = false; + await m.update(1); + assert.equal(m.breaker.state, "active"); + assert.equal(m.breaker.consecutiveErrors, 0); + }); + + it("plan() emits MarketIntents mapping cancels to orderIds", async () => { + const { deps } = makeDeps({ + refreshFails: false, + plan: { cancels: [{ orderId: "0xabc" }], creates: [{ side: "buy", price: 1n, size: 1n }] }, + }); + const m = new MarketRuntime(deps); + await m.start(); + const intents = m.plan(0); + assert.ok(intents); + assert.deepEqual(intents.cancels, [{ orderId: "0xabc" }]); + assert.equal(intents.creates.length, 1); + assert.equal(intents.instrument, deps.instrument); + }); + + it("start() swallows init failures and quarantines instead of throwing", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + (deps.book as unknown as { start: () => Promise }).start = async () => { + throw new Error("init boom"); + }; + const m = new MarketRuntime(deps); + assert.equal(await m.start(), false); + assert.equal(m.breaker.consecutiveErrors, 1); + }); + + it("lazily initializes on the first update() for a market that never started", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + let bootstrapped = 0; + (deps.instrument as unknown as { ownOrders: { bootstrap: () => Promise } }).ownOrders = { + bootstrap: async () => { + bootstrapped++; + }, + }; + const m = new MarketRuntime(deps); + + // Never called start(); the first update must run the late-init path. + await m.update(0); + assert.equal(bootstrapped, 1, "own-order bootstrap ran during late init"); + assert.equal(m.breaker.state, "active"); + assert.ok(m.plan(0), "plans normally once lazily initialized"); + }); + + it("recordRequote delegates to the executor", () => { + const { deps, recorded } = makeDeps({ refreshFails: false, plan: null }); + const m = new MarketRuntime(deps); + m.recordRequote(3, 1); + assert.deepEqual(recorded, [3]); + }); + + it("cancelAll swallows executor failures", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + (deps.executor as unknown as { cancelAll: () => Promise }).cancelAll = async () => { + throw new Error("cancel boom"); + }; + const m = new MarketRuntime(deps); + await m.start(); + await assert.doesNotReject(m.cancelAll()); + }); + + it("stop() stops the book tracker", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + let stopped = 0; + (deps.book as unknown as { stop: () => void }).stop = () => { + stopped++; + }; + const m = new MarketRuntime(deps); + await m.start(); + m.stop(); + assert.equal(stopped, 1); + }); + + it("plan() returns null and records an error when quoting throws", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + (deps.quoter as unknown as { computeQuotes: () => never }).computeQuotes = () => { + throw new Error("quote boom"); + }; + const m = new MarketRuntime(deps); + await m.start(); + assert.equal(m.plan(0), null); + assert.equal(m.breaker.consecutiveErrors, 1); + }); +}); diff --git a/market-maker/tests/core/nonceManager.test.ts b/market-maker/tests/core/nonceManager.test.ts new file mode 100644 index 0000000..60a8e6e --- /dev/null +++ b/market-maker/tests/core/nonceManager.test.ts @@ -0,0 +1,265 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Account, Chain, PublicClient, WalletClient } from "viem"; +import { NonceManager } from "../../src/core/nonceManager.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +const account = { + address: "0x1111111111111111111111111111111111111111", +} as unknown as Account; +const chain = { id: 31337 } as Chain; +const RECEIPT = { gasUsed: 21_000n, effectiveGasPrice: 1_000_000_000n }; + +function pending(): Promise { + return new Promise(() => {}); +} + +interface Mocks { + publicClient: PublicClient; + walletClient: WalletClient; + cancelCalls: { nonce: number }[]; +} + +function makeMocks(opts: { + startNonce: number; + receiptFor: (hash: string) => Promise; +}): Mocks { + const cancelCalls: { nonce: number }[] = []; + const publicClient = { + getTransactionCount: async () => opts.startNonce, + waitForTransactionReceipt: ({ hash }: { hash: string }) => opts.receiptFor(hash), + } as unknown as PublicClient; + const walletClient = { + sendTransaction: async ({ nonce }: { nonce: number }) => { + cancelCalls.push({ nonce }); + return "0xcancel" as const; + }, + } as unknown as WalletClient; + return { publicClient, walletClient, cancelCalls }; +} + +describe("NonceManager", () => { + it("assigns sequential nonces across submits and returns receipts", async () => { + const mocks = makeMocks({ startNonce: 5, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + + const seenNonces: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seenNonces.push(nonce); + return Promise.resolve(`0x${nonce.toString(16)}` as `0x${string}`); + }; + + const a = await nm.submit(broadcast, { maxFeePerGas: 1n, label: "a" }); + const b = await nm.submit(broadcast, { maxFeePerGas: 1n, label: "b" }); + + assert.deepEqual(seenNonces, [5, 6]); + assert.equal(a.gasUsed, RECEIPT.gasUsed); + assert.equal(b.gasUsed, RECEIPT.gasUsed); + }); + + it("serializes concurrent submits so nonces never collide", async () => { + const mocks = makeMocks({ startNonce: 0, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + const seen: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xaa" as const); + }; + await Promise.all([ + nm.submit(broadcast, { maxFeePerGas: 1n, label: "1" }), + nm.submit(broadcast, { maxFeePerGas: 1n, label: "2" }), + nm.submit(broadcast, { maxFeePerGas: 1n, label: "3" }), + ]); + assert.deepEqual(seen, [0, 1, 2]); + }); + + it("replaces by fee on confirmation timeout, reusing the same nonce", async () => { + // First broadcast's receipt never lands; second one confirms. + const mocks = makeMocks({ + startNonce: 9, + receiptFor: (hash) => (hash === "0xfirst" ? pending() : Promise.resolve(RECEIPT)), + }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 20, maxReplacements: 2, replacementFeeBumpPct: 10 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + return Promise.resolve((attempts.length === 1 ? "0xfirst" : "0xsecond") as `0x${string}`); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "rbf" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.equal(attempts.length, 2); + assert.equal(attempts[0].nonce, 9); + assert.equal(attempts[1].nonce, 9); // same nonce + assert.equal(attempts[1].fee, 110n); // +10% + assert.equal(mocks.cancelCalls.length, 0); + }); + + it("escalates to a cancel-tx and advances after exhausting replacements", async () => { + const mocks = makeMocks({ startNonce: 3, receiptFor: () => pending() }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 10, maxReplacements: 1 }, + makeLogger(), + ); + + const broadcast = () => Promise.resolve("0xstuck" as const); + await assert.rejects(nm.submit(broadcast, { maxFeePerGas: 100n, label: "stuck" }), /stuck at nonce 3/); + assert.equal(mocks.cancelCalls.length, 1); + assert.equal(mocks.cancelCalls[0].nonce, 3); + + // Nonce advanced past the wedged one for the next submit. + const seen: number[] = []; + const ok = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + // receiptFor is still "pending" for all hashes, so return a landing one: + return Promise.resolve("0xok" as const); + }; + // Swap receiptFor to resolve now. + (mocks.publicClient as unknown as { waitForTransactionReceipt: unknown }).waitForTransactionReceipt = + async () => RECEIPT; + await nm.submit(ok, { maxFeePerGas: 1n, label: "next" }); + assert.deepEqual(seen, [4]); + }); + + it("fee-bumps and retries a transient submission error, then confirms", async () => { + const mocks = makeMocks({ startNonce: 7, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 2, replacementFeeBumpPct: 20 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + if (attempts.length === 1) return Promise.reject(new Error("nonce too low")); + return Promise.resolve("0xok" as const); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "flaky" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.equal(attempts.length, 2); + assert.equal(attempts[1].nonce, 7, "same nonce reused on retry"); + assert.equal(attempts[1].fee, 120n, "fee bumped +20% after the error"); + assert.equal(mocks.cancelCalls.length, 0, "no cancel-tx for a recovered submit"); + }); + + it("escalates to a cancel-tx and re-reads the nonce after a persistent send error", async () => { + const mocks = makeMocks({ startNonce: 2, receiptFor: async () => RECEIPT }); + let counts = 0; + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + counts++; + return counts === 1 ? 2 : 50; // desync: chain jumps ahead after reset + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 1 }, + makeLogger(), + ); + + const broadcast = () => Promise.reject(new Error("execution reverted")); + await assert.rejects(nm.submit(broadcast, { maxFeePerGas: 100n, label: "dead" }), /execution reverted/); + assert.equal(mocks.cancelCalls.length, 1, "cancel-tx sent to free the wedged nonce"); + assert.equal(mocks.cancelCalls[0].nonce, 2); + + // resetNonce() forced a re-read; next submit picks up the chain's value. + const seen: number[] = []; + await nm.submit( + ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xok" as const); + }, + { maxFeePerGas: 1n, label: "after" }, + ); + assert.deepEqual(seen, [50], "nonce re-read from chain after desync"); + }); + + it("swallows a failing cancel-tx and still advances", async () => { + const mocks = makeMocks({ startNonce: 8, receiptFor: () => pending() }); + (mocks.walletClient as unknown as { sendTransaction: () => Promise }).sendTransaction = + async () => { + throw new Error("cancel broadcast failed"); + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 10, maxReplacements: 0 }, + makeLogger(), + ); + // Timeout with 0 replacements → straight to cancel escalation, which fails + // internally but must not propagate; the stuck error is what surfaces. + await assert.rejects( + nm.submit(() => Promise.resolve("0xstuck" as const), { maxFeePerGas: 1n, label: "wedged" }), + /stuck at nonce 8/, + ); + }); + + it("resetNonce() forces a fresh chain read on the next submit", async () => { + let counts = 0; + const mocks = makeMocks({ startNonce: 0, receiptFor: async () => RECEIPT }); + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + counts++; + return counts === 1 ? 10 : 20; + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + const seen: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xok" as const); + }; + + await nm.submit(broadcast, { maxFeePerGas: 1n, label: "1" }); + nm.resetNonce(); + await nm.submit(broadcast, { maxFeePerGas: 1n, label: "2" }); + assert.deepEqual(seen, [10, 20], "second submit re-read the nonce from chain"); + }); +}); diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 4c67c9f..52e6b64 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -230,3 +230,164 @@ describe("OrderExecutor requote guards (regression)", () => { assert.equal(deps.placedIntents.length, 0, "no unnecessary placements"); }); }); + +// ── plan() / cooldown / gas-spike deferral ───────────────────────────────── + +describe("OrderExecutor.plan", () => { + function spikingGas(): GasTracker { + return { + isGasSpiking: true, + gasSpikePct: 300, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 0n, + } as unknown as GasTracker; + } + + it("defers a requote during a gas spike when drift is below the urgent threshold", () => { + const deps = makeDeps({ gas: spikingGas() }); + const executor = makeExecutor(deps); + // Anchor the last quote at the current oracle price → drift == 0 ticks. + executor.recordRequote(0, 0); + // A count deficit (empty book vs 2 desired) makes a requote warranted… + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + // …but the gas-spike guard defers it because drift (0) < urgent (10). + assert.equal(planned, null); + }); + + it("requotes anyway during a gas spike when drift exceeds the urgent threshold", () => { + const deps = makeDeps({ gas: spikingGas() }); + const executor = makeExecutor(deps); + // No recordRequote → lastQuoteMid is 0 → drift is infinite → proceed. + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + assert.ok(planned); + assert.equal(planned.creates.length, 2); + }); + + it("returns null while inside the requote cooldown", () => { + const deps = makeDeps(); + const executor = new OrderExecutor( + deps.instrument, + makeConfig({ requoteCooldownMs: 60_000 }), + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); + executor.recordRequote(0, 0); // sets lastRequoteAt = now + assert.equal(executor.plan([desiredBuy(95_000_000n)]), null); + }); + + it("returns null when there is no deficit, no stale order, and no drift", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 95_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n); + executor.recordRequote(0, 0); // lastQuoteMid = oracle.currentPrice → drift 0 + assert.equal( + executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]), + null, + ); + }); + + it("records requote stats and timing", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + executor.recordRequote(3, 2); + assert.equal(executor.stats.ordersPlaced, 3); + assert.equal(executor.stats.ordersCancelled, 2); + assert.equal(executor.stats.reconcileCount, 1); + }); +}); + +// ── limit-mode (perps) stale detection ───────────────────────────────────── + +describe("OrderExecutor limit-mode stale detection", () => { + function limitDeps(): TestDeps { + const deps = makeDeps(); + (deps.instrument as unknown as { book: { matchingMode: MatchingMode } }).book.matchingMode = + "limit"; + return deps; + } + + it("keeps orders at-least-as-aggressive as the grid, cancels worse ones", () => { + const deps = limitDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 96_000_000n); // better than worst bid → keep + seedOrder(deps.book, 2, "buy", 93_000_000n); // worse than worst bid → stale + seedOrder(deps.book, 3, "sell", 95_000_000n); // better than worst ask → keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // worse than worst ask → stale + + const planned = executor.plan([ + desiredBuy(95_000_000n), + desiredBuy(94_000_000n), // worst desired bid + desiredSell(96_000_000n), + desiredSell(97_000_000n), // worst desired ask + ]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "worse cancelled"); + assert.ok(!cancelled.has(makeOrderId(1)) && !cancelled.has(makeOrderId(3)), "better kept"); + }); + + it("treats every resting order on a side as stale when that side is absent from the grid", () => { + const deps = limitDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 96_000_000n); + seedOrder(deps.book, 2, "buy", 93_000_000n); + seedOrder(deps.book, 3, "sell", 95_000_000n); // 95 > 96? no → keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // 98 > 96? yes → stale + + // Desired has only an ask side → no desired bid → all resting buys stale. + const planned = executor.plan([desiredSell(96_000_000n)]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(1)) && cancelled.has(makeOrderId(2)), "all bids stale"); + assert.ok(cancelled.has(makeOrderId(4)), "worse ask stale"); + assert.ok(!cancelled.has(makeOrderId(3)), "aggressive ask kept"); + }); +}); + +// ── reconcile() gate + cancelAll ─────────────────────────────────────────── + +describe("OrderExecutor reconcile gate and cancelAll", () => { + it("cancels stale orders but places nothing when the engine denies placement", async () => { + const deps = makeDeps({ + risk: { + throttled: false, + recordGasCost: noop, + canPlaceOrders: async () => false, + } as unknown as RiskManager, + }); + seedOrder(deps.book, 1, "buy", 99_000_000n); // stale vs desired buy@95 + const executor = makeExecutor(deps); + + await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + + assert.equal(deps.placedIntents.length, 0, "denied creates are dropped"); + assert.equal(deps.cancelledOrderIds.length, 1, "stale order still cancelled"); + assert.equal(executor.stats.ordersPlaced, 0); + assert.equal(executor.stats.ordersCancelled, 1); + }); + + it("cancelAll cancels every resting order and records the count", async () => { + const deps = makeDeps(); + seedOrder(deps.book, 1, "buy", 95_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n); + const executor = makeExecutor(deps); + + await executor.cancelAll(); + + assert.equal(deps.cancelledOrderIds.length, 2); + assert.equal(deps.placedIntents.length, 0); + assert.equal(executor.stats.ordersCancelled, 2); + }); + + it("cancelAll is a no-op on an empty book", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + await executor.cancelAll(); + assert.equal(deps.cancelledOrderIds.length, 0); + }); +}); diff --git a/market-maker/tests/core/portfolioCollateral.test.ts b/market-maker/tests/core/portfolioCollateral.test.ts new file mode 100644 index 0000000..e570f5e --- /dev/null +++ b/market-maker/tests/core/portfolioCollateral.test.ts @@ -0,0 +1,151 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { PublicClient } from "viem"; +import { PortfolioCollateralAccount } from "../../src/core/portfolioCollateral.ts"; +import type { + BatchableCollateralAccount, + CollateralAccount, + CollateralSnapshot, + MarginReadPlan, +} from "../../src/core/adapter.ts"; + +/** A multicall mock that echoes each contract's `args[0]` back as its result. */ +function makeMulticallSpy(): { publicClient: PublicClient; state: { calls: number } } { + const state = { calls: 0 }; + const publicClient = { + multicall: async ({ contracts }: { contracts: { args: unknown[] }[] }) => { + state.calls++; + return contracts.map((c) => c.args[0]); + }, + } as unknown as PublicClient; + return { publicClient, state }; +} + +function reads(values: bigint[]): MarginReadPlan["shared"] { + return values.map((v) => ({ + address: "0x0000000000000000000000000000000000000001", + abi: [], + functionName: "x", + args: [v], + })) as unknown as MarginReadPlan["shared"]; +} + +const SHARED = [100n, 200n, 300n, 400n, 500n]; // vault, IM, MM, wallet, native + +function decodeShared(results: readonly unknown[]): Omit { + const r = results as bigint[]; + return { + vaultBalance: r[0], + portfolioIM: r[1], + portfolioMM: r[2], + walletTokenBalance: r[3], + nativeBalance: r[4], + collateralToken: "0x00000000000000000000000000000000000000aa", + }; +} + +function makeBatchable( + sharedValues: bigint[], + orderMargin: bigint, + pnl: bigint, +): BatchableCollateralAccount { + const buildMarginReadPlan = async (): Promise => ({ + shared: reads(sharedValues), + venue: reads([orderMargin, pnl]), + decode: (results) => ({ + ...decodeShared(results), + venueOrderMargin: (results as bigint[])[5], + venueUnrealizedPnl: (results as bigint[])[6], + }), + }); + return { + buildMarginReadPlan, + snapshot: async () => { + const plan = await buildMarginReadPlan(); + return plan.decode([...sharedValues, orderMargin, pnl]); + }, + imSpotShock: async () => 0n, + deposit: async () => {}, + canPlace: async () => true, + }; +} + +describe("PortfolioCollateralAccount", () => { + it("batches all venues into one multicall, reading shared state once", async () => { + const spy = makeMulticallSpy(); + const a = makeBatchable(SHARED, 11n, 22n); + // b's shared values are ignored (aggregator reads shared from the first plan). + const b = makeBatchable([9n, 9n, 9n, 9n, 9n], 33n, 44n); + const acct = new PortfolioCollateralAccount([a, b], spy.publicClient); + + const snap = await acct.snapshot(); + + assert.equal(spy.state.calls, 1); // single RPC round trip + assert.equal(snap.vaultBalance, 100n); // shared from first plan + assert.equal(snap.portfolioIM, 200n); + assert.equal(snap.venueOrderMargin, 44n); // 11 + 33 + assert.equal(snap.venueUnrealizedPnl, 66n); // 22 + 44 + }); + + it("falls back to per-account snapshot when an account is not batchable", async () => { + const spy = makeMulticallSpy(); + const legacy: CollateralAccount = { + snapshot: async () => ({ + vaultBalance: 1_000n, + portfolioIM: 50n, + portfolioMM: 25n, + venueOrderMargin: 7n, + venueUnrealizedPnl: -3n, + walletTokenBalance: 0n, + nativeBalance: 0n, + collateralToken: "0x00000000000000000000000000000000000000aa", + }), + imSpotShock: async () => 0n, + deposit: async () => {}, + canPlace: async () => true, + }; + const batchable = makeBatchable(SHARED, 5n, 5n); + const acct = new PortfolioCollateralAccount([legacy, batchable], spy.publicClient); + + const snap = await acct.snapshot(); + assert.equal(spy.state.calls, 0); // no batched multicall; each account snapshots itself + assert.equal(snap.vaultBalance, 1_000n); // primary = first (legacy) + assert.equal(snap.venueOrderMargin, 12n); // 7 + 5 + assert.equal(snap.venueUnrealizedPnl, 2n); // -3 + 5 + }); + + it("rejects construction with no accounts", () => { + const spy = makeMulticallSpy(); + assert.throws( + () => new PortfolioCollateralAccount([], spy.publicClient), + /at least one account/, + ); + }); + + it("delegates the shared-vault operations to the first account", async () => { + const spy = makeMulticallSpy(); + const calls: string[] = []; + const primary: CollateralAccount = { + snapshot: async () => makeBatchable(SHARED, 0n, 0n).snapshot(), + imSpotShock: async () => { + calls.push("shock"); + return 42n; + }, + deposit: async (amount) => { + calls.push(`deposit:${amount}`); + }, + canPlace: async (im) => { + calls.push(`canPlace:${im}`); + return im < 100n; + }, + }; + const secondary = makeBatchable(SHARED, 1n, 1n); + const acct = new PortfolioCollateralAccount([primary, secondary], spy.publicClient); + + assert.equal(await acct.imSpotShock(), 42n); + await acct.deposit(7n); + assert.equal(await acct.canPlace(50n), true); + assert.equal(await acct.canPlace(150n), false); + assert.deepEqual(calls, ["shock", "deposit:7", "canPlace:50", "canPlace:150"]); + }); +}); diff --git a/market-maker/tests/core/portfolioRunner.test.ts b/market-maker/tests/core/portfolioRunner.test.ts new file mode 100644 index 0000000..cd1cba6 --- /dev/null +++ b/market-maker/tests/core/portfolioRunner.test.ts @@ -0,0 +1,395 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyRoll, + gasCostUsd, + runPortfolioTick, + type PortfolioTickDeps, + type PortfolioTickState, +} from "../../src/core/portfolioRunner.ts"; +import type { MarketRuntime } from "../../src/core/marketRuntime.ts"; +import type { InstrumentAdapter } from "../../src/core/adapter.ts"; +import type { MarketIntents, SubmitResult } from "../../src/core/txCoordinator.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => makeLogger(), + info: noop, + warn: noop, + error: noop, + debug: noop, + } as never; +} + +const RECEIPT = { gasUsed: 100_000n, effectiveGasPrice: 2_000_000_000n }; + +interface FakeMarket { + runtime: MarketRuntime; + calls: { + updated: number; + planned: number; + cancelAll: number; + started: number; + stopped: number; + requotes: { placed: number; cancelled: number }[]; + }; +} + +/** + * A stand-in `MarketRuntime`. `plan` returns fixed intents so we can drive the + * runner's staging (pause/halt/roll/attribution) without any on-chain wiring. + */ +function makeMarket( + id: string, + planResult: { cancels: number; creates: number } | null, +): FakeMarket { + const instrument = { id } as unknown as InstrumentAdapter; + const calls: FakeMarket["calls"] = { + updated: 0, + planned: 0, + cancelAll: 0, + started: 0, + stopped: 0, + requotes: [], + }; + const runtime = { + id, + instrument, + update: async () => { + calls.updated++; + }, + plan: (): MarketIntents | null => { + calls.planned++; + if (!planResult) return null; + return { + instrument, + cancels: Array.from({ length: planResult.cancels }, (_, i) => ({ + orderId: `0x${i.toString(16).padStart(64, "0")}` as `0x${string}`, + })), + creates: Array.from({ length: planResult.creates }, () => ({ + side: "buy" as const, + price: 1n, + size: 1n, + })), + }; + }, + cancelAll: async () => { + calls.cancelAll++; + }, + start: async () => { + calls.started++; + return true; + }, + stop: () => { + calls.stopped++; + }, + recordRequote: (placed: number, cancelled: number) => { + calls.requotes.push({ placed, cancelled }); + }, + } as unknown as MarketRuntime; + return { runtime, calls }; +} + +interface DepOverrides { + sharedThrows?: boolean; + riskOk?: boolean; + submit?: (all: MarketIntents[]) => Promise; + graceMs?: number; + rollCheckIntervalMs?: number; + onRoll?: PortfolioTickDeps["onRoll"]; +} + +interface Tracked { + deps: PortfolioTickDeps; + health: { status: string; lastError: unknown }; + spies: { + gasUpdates: number; + collateralUpdates: number; + topUps: number; + riskChecks: number; + recordedGas: bigint[]; + submits: MarketIntents[][]; + }; +} + +function makeDeps(over: DepOverrides = {}): Tracked { + const health = { status: "init", lastError: null as unknown }; + const spies: Tracked["spies"] = { + gasUpdates: 0, + collateralUpdates: 0, + topUps: 0, + riskChecks: 0, + recordedGas: [], + submits: [], + }; + + const defaultSubmit = async (all: MarketIntents[]): Promise => ({ + receipts: [RECEIPT], + errors: [], + ordersPlaced: all.reduce((n, i) => n + i.creates.length, 0), + ordersCancelled: all.reduce((n, i) => n + i.cancels.length, 0), + gateDenied: false, + }); + + const deps: PortfolioTickDeps = { + gas: { + update: async () => { + spies.gasUpdates++; + if (over.sharedThrows) throw new Error("gas rpc down"); + }, + cappedGasPrice: () => 3_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as PortfolioTickDeps["gas"], + collateral: { + update: async () => { + spies.collateralUpdates++; + }, + maybeTopUp: async () => { + spies.topUps++; + }, + canPlace: async () => true, + } as unknown as PortfolioTickDeps["collateral"], + risk: { + check: () => { + spies.riskChecks++; + return over.riskOk ?? true; + }, + haltReason: { message: "drawdown breach" }, + recordGasCost: (usd: bigint) => spies.recordedGas.push(usd), + } as unknown as PortfolioTickDeps["risk"], + coordinator: { + submit: async (all: MarketIntents[]) => { + spies.submits.push(all); + return (over.submit ?? defaultSubmit)(all); + }, + } as unknown as PortfolioTickDeps["coordinator"], + health: health as unknown as PortfolioTickDeps["health"], + logger: makeLogger(), + dryRun: false, + graceMs: over.graceMs ?? 30_000, + rollCheckIntervalMs: over.rollCheckIntervalMs ?? 60_000, + onRoll: over.onRoll, + }; + return { deps, health, spies }; +} + +function makeState( + markets: MarketRuntime[], + over: Partial = {}, +): PortfolioTickState { + return { + markets, + lastSharedOkAt: 0, + pauseNew: false, + lastRollAt: 0, + ...over, + }; +} + +describe("runPortfolioTick", () => { + it("happy path: updates, plans, submits, records gas and requotes", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, health, spies } = makeDeps(); + const now = 1_000; + + const res = await runPortfolioTick(now, deps, makeState([m.runtime], { lastSharedOkAt: now })); + + assert.equal(res.halted, false); + assert.equal(spies.gasUpdates, 1); + assert.equal(spies.collateralUpdates, 1); + assert.equal(spies.topUps, 1); + assert.equal(m.calls.updated, 1); + assert.equal(spies.submits.length, 1); + assert.equal(spies.submits[0][0].creates.length, 2, "creates kept when fresh"); + // Gas recorded per receipt, requote attributed to the planning market. + assert.deepEqual(spies.recordedGas, [gasCostUsd(RECEIPT, 2_000_000_000n)]); + assert.deepEqual(m.calls.requotes, [{ placed: 2, cancelled: 1 }]); + assert.equal(health.status, "running"); + assert.equal(health.lastError, null, "clears stale error after a clean tick"); + assert.equal(res.state.pauseNew, false); + }); + + it("skips submission and clears a stale error on an idle tick", async () => { + const m = makeMarket("perps", null); + const { deps, health, spies } = makeDeps(); + health.lastError = { message: "stale from an earlier tick" }; + const res = await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(spies.submits.length, 0); + assert.equal(res.halted, false); + assert.equal(health.lastError, null, "idle fresh tick clears the prior error"); + }); + + it("clears a stale error after a clean active (submitting) tick", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, health } = makeDeps(); + health.lastError = { message: "revert from a previous tick" }; + await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(health.lastError, null, "recovered active tick must clear the stale error"); + }); + + it("surfaces a shared-input failure onto health even within the grace window", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 0 }); + const { deps, health } = makeDeps({ sharedThrows: true, graceMs: 30_000 }); + await runPortfolioTick(5_000, deps, makeState([m.runtime], { lastSharedOkAt: 0 })); + assert.equal((health.lastError as { message: string }).message, "gas rpc down"); + }); + + it("keeps placing within the staleness grace window on a shared-input failure", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, spies } = makeDeps({ sharedThrows: true, graceMs: 30_000 }); + // Failure happened only 5s after the last good refresh → still in grace. + const res = await runPortfolioTick(5_000, deps, makeState([m.runtime], { lastSharedOkAt: 0 })); + + assert.equal(res.state.pauseNew, false, "not paused inside grace"); + assert.equal(spies.riskChecks, 0, "risk gate skipped on stale shared data"); + assert.equal(spies.submits.length, 1); + assert.equal(spies.submits[0][0].creates.length, 2, "creates still allowed in grace"); + }); + + it("pauses new placements (keeps cancels) when shared inputs are stale past grace", async () => { + const withWork = makeMarket("perps", { cancels: 1, creates: 2 }); + const cancelsOnly = makeMarket("futures", { cancels: 3, creates: 0 }); + const createsOnly = makeMarket("futures2", { cancels: 0, creates: 4 }); + const { deps, spies } = makeDeps({ sharedThrows: true, graceMs: 10_000 }); + + const res = await runPortfolioTick( + 100_000, + deps, + makeState([withWork.runtime, cancelsOnly.runtime, createsOnly.runtime], { lastSharedOkAt: 0 }), + ); + + assert.equal(res.state.pauseNew, true); + const submitted = spies.submits[0]; + // createsOnly is filtered out (no cancels, creates stripped); others keep cancels only. + assert.equal(submitted.length, 2); + for (const intent of submitted) assert.equal(intent.creates.length, 0, "creates stripped"); + assert.equal(submitted.reduce((n, i) => n + i.cancels.length, 0), 4); + }); + + it("halts and cancels every market on a confirmed risk breach with fresh data", async () => { + const a = makeMarket("perps", { cancels: 0, creates: 2 }); + const b = makeMarket("futures", { cancels: 0, creates: 2 }); + const { deps, health, spies } = makeDeps({ riskOk: false }); + + const res = await runPortfolioTick( + 1_000, + deps, + makeState([a.runtime, b.runtime], { lastSharedOkAt: 1_000 }), + ); + + assert.equal(res.halted, true); + assert.equal(a.calls.cancelAll, 1); + assert.equal(b.calls.cancelAll, 1); + assert.equal(spies.submits.length, 0, "no submissions after a halt"); + assert.equal(health.status, "error"); + assert.deepEqual(health.lastError, { message: "drawdown breach" }); + }); + + it("does not halt on a would-be breach when shared data is stale", async () => { + const a = makeMarket("perps", { cancels: 1, creates: 0 }); + const { deps, spies } = makeDeps({ sharedThrows: true, riskOk: false, graceMs: 10_000 }); + + const res = await runPortfolioTick(1_000, deps, makeState([a.runtime], { lastSharedOkAt: 0 })); + + assert.equal(res.halted, false, "stale data must not trigger a halt"); + assert.equal(spies.riskChecks, 0); + assert.equal(a.calls.cancelAll, 0); + }); + + it("surfaces a submission error onto health without halting", async () => { + const m = makeMarket("perps", { cancels: 0, creates: 1 }); + const { deps, health } = makeDeps({ + submit: async () => ({ + receipts: [], + errors: [new Error("venue revert")], + ordersPlaced: 0, + ordersCancelled: 0, + gateDenied: false, + }), + }); + + const res = await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(res.halted, false); + assert.equal(health.status, "running"); + assert.equal((health.lastError as { message: string }).message, "venue revert"); + }); + + it("runs the roll when the interval has elapsed and swaps the market set", async () => { + const stay = makeMarket("futures@1", { cancels: 0, creates: 0 }); + const dropped = makeMarket("futures@2", { cancels: 0, creates: 0 }); + const added = makeMarket("futures@3", { cancels: 0, creates: 0 }); + const { deps } = makeDeps({ + rollCheckIntervalMs: 1_000, + onRoll: async () => ({ add: [added.runtime], removeIds: ["futures@2"] }), + }); + + const res = await runPortfolioTick( + 5_000, + deps, + makeState([stay.runtime, dropped.runtime], { lastSharedOkAt: 5_000, lastRollAt: 0 }), + ); + + assert.deepEqual( + res.state.markets.map((mk) => mk.id), + ["futures@1", "futures@3"], + ); + assert.equal(dropped.calls.cancelAll, 1); + assert.equal(dropped.calls.stopped, 1); + assert.equal(added.calls.started, 1); + assert.equal(res.state.lastRollAt, 5_000); + }); +}); + +describe("applyRoll", () => { + it("cancels+stops removed markets, starts added ones, and splices the set", async () => { + const keep = makeMarket("a", null); + const drop = makeMarket("b", null); + const add = makeMarket("c", null); + const next = await applyRoll( + [keep.runtime, drop.runtime], + async () => ({ add: [add.runtime], removeIds: ["b"] }), + makeLogger(), + ); + assert.deepEqual(next.map((m) => m.id), ["a", "c"]); + assert.equal(drop.calls.cancelAll, 1); + assert.equal(drop.calls.stopped, 1); + assert.equal(add.calls.started, 1); + assert.equal(keep.calls.cancelAll, 0); + }); + + it("is a no-op that keeps the same set when there is nothing to roll", async () => { + const keep = makeMarket("a", null); + const current = [keep.runtime]; + const next = await applyRoll(current, async () => ({ add: [], removeIds: [] }), makeLogger()); + assert.equal(next, current, "returns the same reference"); + assert.equal(keep.calls.started, 0); + assert.equal(keep.calls.stopped, 0); + }); + + it("keeps the current set when the roll callback throws", async () => { + const keep = makeMarket("a", null); + const current = [keep.runtime]; + const next = await applyRoll( + current, + async () => { + throw new Error("venue read failed"); + }, + makeLogger(), + ); + assert.equal(next, current); + assert.equal(keep.calls.cancelAll, 0); + }); +}); + +describe("gasCostUsd", () => { + it("returns 0 when the ETH price is unknown", () => { + assert.equal(gasCostUsd({ gasUsed: 21_000n, effectiveGasPrice: 1n }, 0n), 0n); + }); + + it("scales gasUsed * price * ethUsd down by 1e18", () => { + // 100000 gas * 2 gwei * $2000 (8-dp) / 1e18 + const cost = gasCostUsd({ gasUsed: 100_000n, effectiveGasPrice: 2_000_000_000n }, 2_000_000_000n); + assert.equal(cost, (100_000n * 2_000_000_000n * 2_000_000_000n) / 10n ** 18n); + }); +}); diff --git a/market-maker/tests/core/riskManager.test.ts b/market-maker/tests/core/riskManager.test.ts index 94a79d8..7870c1c 100644 --- a/market-maker/tests/core/riskManager.test.ts +++ b/market-maker/tests/core/riskManager.test.ts @@ -169,6 +169,38 @@ describe("RiskManager", () => { assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: false }); }); + it("allowedSides: uses the per-market inventory and cap over the shared ones", () => { + // Shared inventory is neutral with a large cap; the per-market override is + // at its own (smaller) long cap, so bids must be blocked for THIS market. + const r = new RiskManager( + makeConfig({ maxPositionSize: 1_000_000_000n }), + makeInventory({ netQuantity: 0n }), + makeCollateral({ utilizationPct: 10 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + const perMarket = makeInventory({ netQuantity: 5_000_000n }); + assert.deepEqual(r.allowedSides(perMarket, 5_000_000n), { + quoteBid: false, // net == cap → cannot add more long + quoteAsk: true, + }); + // Sanity: without the override it would use the shared neutral inventory. + assert.deepEqual(r.allowedSides(), { quoteBid: true, quoteAsk: true }); + }); + + it("allowedSides: blocks both sides when there is no inventory to reason about", () => { + const r = new RiskManager( + makeConfig(), + null, + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); + assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: false }); + }); + it("throttles when hourly gas budget exceeded", () => { const r = new RiskManager( makeConfig({ maxGasBudgetPerHourUsd: 10_000_000n }), diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts new file mode 100644 index 0000000..e4d7d35 --- /dev/null +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -0,0 +1,163 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { TxCoordinator, type MarketIntents } from "../../src/core/txCoordinator.ts"; +import type { NonceManager } from "../../src/core/nonceManager.ts"; +import type { InstrumentAdapter, VenueAdapter } from "../../src/core/adapter.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +const RECEIPT = { gasUsed: 21_000n, effectiveGasPrice: 1n }; + +/** Fake NonceManager: runs the broadcast (triggering venue.multicall) once. */ +function makeNonce(): { nm: NonceManager; submitCount: () => number } { + let count = 0; + const nm = { + submit: async ( + broadcast: (p: { nonce: number; maxFeePerGas: bigint }) => Promise<`0x${string}`>, + opts: { maxFeePerGas: bigint }, + ) => { + count++; + await broadcast({ nonce: count, maxFeePerGas: opts.maxFeePerGas }); + return RECEIPT; + }, + } as unknown as NonceManager; + return { nm, submitCount: () => count }; +} + +function makeVenue(kind: "perps" | "futures", opts: { fail?: boolean } = {}) { + const batches: `0x${string}`[][] = []; + const venue = { + kind, + multicall: async (calls: `0x${string}`[]) => { + if (opts.fail) throw new Error(`${kind} boom`); + batches.push(calls); + return "0xhash" as const; + }, + } as unknown as VenueAdapter; + return { venue, batches }; +} + +function makeMarket( + venue: VenueAdapter, + id: string, + cancels: string[], + creates: { price: bigint; im: bigint }[], +): MarketIntents { + const instrument = { + id, + venue, + encodeCancel: (c: { orderId: `0x${string}` }) => `0xC${c.orderId.slice(2)}` as `0x${string}`, + encodeCreate: (o: { price: bigint }) => `0xO${o.price.toString()}` as `0x${string}`, + estimateOrderMargin: (o: { price: bigint }) => + creates.find((c) => c.price === o.price)?.im ?? 0n, + } as unknown as InstrumentAdapter; + return { + instrument, + cancels: cancels.map((o) => ({ orderId: o as `0x${string}` })), + creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: 1n })), + }; +} + +describe("TxCoordinator", () => { + it("runs one aggregate gate summing IM across all markets' creates", async () => { + const { nm } = makeNonce(); + const seen: bigint[] = []; + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", [], [{ price: 1n, im: 300n }]), + makeMarket(venue, "f2", [], [{ price: 2n, im: 400n }]), + ]; + await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async (im) => { + seen.push(im); + return true; + }, + }); + assert.deepEqual(seen, [700n]); // 300 + 400, one call + }); + + it("drops creates but keeps cancels when the gate denies", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xdead"], [{ price: 1n, im: 300n }])]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async () => false, + }); + assert.equal(res.gateDenied, true); + assert.equal(res.ordersPlaced, 0); + assert.equal(res.ordersCancelled, 1); + assert.equal(submitCount(), 1); + assert.deepEqual(batches[0], ["0xCdead"]); // only the cancel encoded + }); + + it("merges same-venue markets into one batch, cancels before creates", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }]), + makeMarket(venue, "f2", ["0xb"], [{ price: 2n, im: 0n }]), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 1); // one venue → one tx + assert.deepEqual(batches[0], ["0xCa", "0xCb", "0xO1", "0xO2"]); + }); + + it("isolates venue failures: one venue's revert doesn't block the other", async () => { + const { nm } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const perps = makeVenue("perps", { fail: true }); + const futures = makeVenue("futures"); + const markets = [ + makeMarket(perps.venue, "p", ["0x1"], []), + makeMarket(futures.venue, "f", ["0x2"], []), + ]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async () => true, + }); + assert.equal(res.errors.length, 1); + assert.equal(res.receipts.length, 1); // futures still submitted + assert.equal(futures.batches.length, 1); + }); + + it("chunks a venue batch that exceeds maxCallsPerTx", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", ["0xa", "0xb", "0xc", "0xd", "0xe"], []), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 3); // 5 calls / 2 = 3 chunks + assert.deepEqual(batches.map((b) => b.length), [2, 2, 1]); + }); + + it("dry run submits nothing but reports intended counts", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }])]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: true, + canPlace: async () => true, + }); + assert.equal(submitCount(), 0); + assert.equal(batches.length, 0); + assert.equal(res.ordersCancelled, 1); + assert.equal(res.ordersPlaced, 1); + }); +}); From 3d69ec78304b258c0a4e2f4e7be8865dbba26f58 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 8 Jul 2026 19:44:23 +0200 Subject: [PATCH 081/180] chore: pin keeper + market-maker contract ABI deps to merged dev commits Repin derivatives-marketplace (c8078b9e) and futures-marketplace (5e28bb34) to exact post-merge dev commits so lockfiles and CI resolve the ABIs carrying CONTRACT_SIZE_HPS_DAY, avoiding pnpm's cached #dev branch drift. --- .github/workflows/keeper-test.yml | 9 +-- keeper/package.json | 4 +- keeper/pnpm-lock.yaml | 20 ++--- market-maker/package.json | 6 +- market-maker/pnpm-lock.yaml | 117 +++++++++++++++--------------- 5 files changed, 76 insertions(+), 80 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 2af4944..9597f96 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -63,23 +63,18 @@ jobs: working-directory: ./contracts run: pnpm install --frozen-lockfile - # TEMPORARY: pinned to feat/liquidate-to-im-buffer because the keeper's - # liquidate-to-IM-buffer orchestration calls contract functions - # (`liquidatePositions` on futures, `liquidatePosition(user, closeQty)` on - # perps) that only exist on those feature branches. Revert both refs back - # to `dev` once derivatives-marketplace#73 and futures-marketplace#203 merge. - name: Checkout derivatives-marketplace (perps contracts) uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - ref: feat/liquidate-to-im-buffer + ref: c8078b9e430df8a5ec8bc64865d692b38cc69d8c path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - ref: feat/liquidate-to-im-buffer + ref: 5e28bb346b125d90eb138370a8a78c69487d1c26 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/package.json b/keeper/package.json index 3b54b66..93ad925 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,8 +21,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 0a1becd..d1efb4f 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -20,11 +20,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -150,8 +150,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/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} version: 0.0.0 end-of-stream@1.4.5: @@ -166,8 +166,8 @@ 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/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} version: 0.0.0 help-me@5.0.0: @@ -352,7 +352,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/market-maker/package.json b/market-maker/package.json index e2c578a..31d2cb0 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -35,11 +35,11 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amaro": "^1.1.9", - "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts", + "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts", "fraction.js": "^5.2.2", - "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 547aaa8..c3b8829 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -21,20 +21,20 @@ importers: specifier: ^1.1.9 version: 1.1.9 collateral-margin-contracts: - specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3) + specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3) fraction.js: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#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 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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)) + specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1133,11 +1133,8 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1239,13 +1236,13 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc} + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a} version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 color-convert@2.0.1: @@ -1331,8 +1328,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc} + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} version: 1.0.0 engines: {node: 24.x} @@ -1545,8 +1542,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} version: 1.0.0 engines: {node: 24.x} @@ -1676,8 +1673,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - immutable@4.3.8: - resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -1789,14 +1786,18 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stream-stringify@3.1.6: - resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} + json-stream-stringify@3.1.7: + resolution: {integrity: sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==} engines: {node: '>=7.10.1'} jsonfile@4.0.0: @@ -2056,8 +2057,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pify@4.0.1: @@ -2372,8 +2373,8 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tmp@0.0.33: @@ -2559,8 +2560,8 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} yocto-queue@0.1.0: @@ -3108,7 +3109,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3151,7 +3152,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4340,11 +4341,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@2.1.1: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 @@ -4469,7 +4466,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3): + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3): dependencies: '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) @@ -4481,7 +4478,7 @@ snapshots: - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} color-convert@2.0.1: dependencies: @@ -4564,14 +4561,14 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#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)): + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@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/ef77952259de3abc1b219e18618ef3fb49ebce79 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 @@ -4777,9 +4774,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fill-range@7.1.1: dependencies: @@ -4847,7 +4844,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#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 @@ -4856,7 +4853,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 @@ -4949,9 +4946,9 @@ snapshots: find-up: 5.0.0 fp-ts: 1.19.3 fs-extra: 7.0.1 - immutable: 4.3.8 + immutable: 4.3.9 io-ts: 1.10.4 - json-stream-stringify: 3.1.6 + json-stream-stringify: 3.1.7 keccak: 3.0.4 lodash: 4.18.1 micro-eth-signer: 0.14.0 @@ -4965,7 +4962,7 @@ snapshots: solc: 0.8.26(debug@4.4.3) source-map-support: 0.5.21 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 @@ -5048,7 +5045,7 @@ snapshots: ignore@5.3.2: {} - immutable@4.3.8: {} + immutable@4.3.9: {} import-fresh@3.3.1: dependencies: @@ -5140,11 +5137,15 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} - json-stream-stringify@3.1.6: {} + json-stream-stringify@3.1.7: {} jsonfile@4.0.0: optionalDependencies: @@ -5234,11 +5235,11 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 2.1.2 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimist@1.2.8: {} @@ -5257,7 +5258,7 @@ snapshots: find-up: 5.0.0 glob: 8.1.0 he: 1.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 log-symbols: 4.1.0 minimatch: 5.1.9 ms: 2.1.3 @@ -5265,7 +5266,7 @@ snapshots: strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 6.5.1 - yargs: 16.2.0 + yargs: 16.2.2 yargs-parser: 20.2.9 yargs-unparser: 2.0.0 @@ -5405,7 +5406,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} pify@4.0.1: {} @@ -5710,10 +5711,10 @@ snapshots: dependencies: real-require: 0.2.0 - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tmp@0.0.33: dependencies: @@ -5854,7 +5855,7 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@16.2.0: + yargs@16.2.2: dependencies: cliui: 7.0.4 escalade: 3.2.0 From ea9a46a9a840da9421e7f26badb74b1357dd27ff Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 9 Jul 2026 16:19:56 +0200 Subject: [PATCH 082/180] ci: fix flaky subgraph tests and unreachable keeper health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the Matchstick binary to 0.6.0 in both indexers so `graph test` no longer hits the GitHub API for the "latest" release tag — that call gets rate-limited on CI and returns no tag_name, throwing "Invalid version. Must be a string" and breaking the points-subgraph deploy. Drop the public /health smoke-test from the keeper deploy: the keeper is behind an internal (VPC/VPN-only) ALB, so keeper.{env}.hashpower.exchange is unreachable from GitHub-hosted runners. Rollout health is already asserted via the ECS deployment state + ALB target-group health check. --- .github/workflows/deploy-keeper.yml | 38 +++++++---------------------- indexer/package.json | 2 +- points-indexer/package.json | 2 +- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index d3c6138..ec1b2b8 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -447,35 +447,15 @@ jobs: fi echo "✅ Service converged on the deployed revision." - - name: Smoke-test /health reports the deployed version - if: steps.rollout.outputs.diagnose == 'true' - env: - HEALTH_URL: ${{ needs.build.outputs.keeper_health_url }} - EXPECTED_VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - echo "🔎 GET ${HEALTH_URL} (expecting version=${EXPECTED_VERSION})" - - # Give the new task a moment to bind the health port, then poll. - ATTEMPTS=12 - for i in $(seq 1 "$ATTEMPTS"); do - BODY=$(curl -fsS -m 10 "$HEALTH_URL" 2>/dev/null || true) - if [ -n "$BODY" ]; then - STATUS=$(echo "$BODY" | jq -r '.status // "?"') - LIVE_VERSION=$(echo "$BODY" | jq -r '.info.version // "?"') - echo " attempt ${i}: status=${STATUS} version=${LIVE_VERSION}" - if [ "$STATUS" = "ok" ] && [ "$LIVE_VERSION" = "$EXPECTED_VERSION" ]; then - echo "✅ /health is OK and serving the deployed version." - exit 0 - fi - else - echo " attempt ${i}: no response yet" - fi - sleep 10 - done - - echo "::error::/health never reported status=ok with version=${EXPECTED_VERSION}. Last body: ${BODY:-}" - exit 1 + # NOTE: There is intentionally no public HTTP smoke-test of /health here. + # The keeper sits behind an INTERNAL ALB (see + # .bedrock/.terragrunt/06_col_mar_keeper_svc.tf) that only accepts traffic + # from the VPC CIDR and VPN range — keeper.{env}.hashpower.exchange is not + # resolvable/reachable from GitHub-hosted runners on the public internet. + # Health is already proven above: the ALB target group runs a /health + # check, and ECS only reports rolloutState=COMPLETED once the new tasks + # pass it, which the rollout verification asserts alongside the exact + # deployed task-def ARN. - name: Diagnose failed rollout if: failure() && steps.rollout.outputs.diagnose == 'true' diff --git a/indexer/package.json b/indexer/package.json index 11b6e94..1972e8e 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ collateral-vault", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 collateral-vault", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground" }, diff --git a/points-indexer/package.json b/points-indexer/package.json index 4fa4feb..18f3d1a 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ points", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 points", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "test:integration": "hardhat test nodejs", "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", "indexer": "docker compose --env-file ../.env up", From b3a7f196307a3e8d3eb920b25857664507ff779a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 9 Jul 2026 16:19:56 +0200 Subject: [PATCH 083/180] ci: fix flaky subgraph tests and unreachable keeper health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the Matchstick binary to 0.6.0 in both indexers so `graph test` no longer hits the GitHub API for the "latest" release tag — that call gets rate-limited on CI and returns no tag_name, throwing "Invalid version. Must be a string" and breaking the points-subgraph deploy. Drop the public /health smoke-test from the keeper deploy: the keeper is behind an internal (VPC/VPN-only) ALB, so keeper.{env}.hashpower.exchange is unreachable from GitHub-hosted runners. Rollout health is already asserted via the ECS deployment state + ALB target-group health check. --- .github/workflows/deploy-keeper.yml | 38 +++++++---------------------- indexer/package.json | 2 +- points-indexer/package.json | 2 +- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index d3c6138..ec1b2b8 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -447,35 +447,15 @@ jobs: fi echo "✅ Service converged on the deployed revision." - - name: Smoke-test /health reports the deployed version - if: steps.rollout.outputs.diagnose == 'true' - env: - HEALTH_URL: ${{ needs.build.outputs.keeper_health_url }} - EXPECTED_VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - echo "🔎 GET ${HEALTH_URL} (expecting version=${EXPECTED_VERSION})" - - # Give the new task a moment to bind the health port, then poll. - ATTEMPTS=12 - for i in $(seq 1 "$ATTEMPTS"); do - BODY=$(curl -fsS -m 10 "$HEALTH_URL" 2>/dev/null || true) - if [ -n "$BODY" ]; then - STATUS=$(echo "$BODY" | jq -r '.status // "?"') - LIVE_VERSION=$(echo "$BODY" | jq -r '.info.version // "?"') - echo " attempt ${i}: status=${STATUS} version=${LIVE_VERSION}" - if [ "$STATUS" = "ok" ] && [ "$LIVE_VERSION" = "$EXPECTED_VERSION" ]; then - echo "✅ /health is OK and serving the deployed version." - exit 0 - fi - else - echo " attempt ${i}: no response yet" - fi - sleep 10 - done - - echo "::error::/health never reported status=ok with version=${EXPECTED_VERSION}. Last body: ${BODY:-}" - exit 1 + # NOTE: There is intentionally no public HTTP smoke-test of /health here. + # The keeper sits behind an INTERNAL ALB (see + # .bedrock/.terragrunt/06_col_mar_keeper_svc.tf) that only accepts traffic + # from the VPC CIDR and VPN range — keeper.{env}.hashpower.exchange is not + # resolvable/reachable from GitHub-hosted runners on the public internet. + # Health is already proven above: the ALB target group runs a /health + # check, and ECS only reports rolloutState=COMPLETED once the new tasks + # pass it, which the rollout verification asserts alongside the exact + # deployed task-def ARN. - name: Diagnose failed rollout if: failure() && steps.rollout.outputs.diagnose == 'true' diff --git a/indexer/package.json b/indexer/package.json index 11b6e94..1972e8e 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ collateral-vault", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 collateral-vault", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground" }, diff --git a/points-indexer/package.json b/points-indexer/package.json index 4fa4feb..18f3d1a 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ points", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 points", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "test:integration": "hardhat test nodejs", "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", "indexer": "docker compose --env-file ../.env up", From 02c4958d7976f2bba1a591422b2aa04d624472a1 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 9 Jul 2026 16:55:34 +0200 Subject: [PATCH 084/180] test: fix position size for the cross venue test --- keeper/tests/integration/scenarios.ts | 45 +++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 68953fc..e853026 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -751,30 +751,35 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { * in the partial regime (distinct from both the single-venue-suffices partial * test and the 99.8% deep-crash test that wipes everything into bad debt). * - * Staged at a $40 mark (crash to $30) so the per-lot stress clears the flat - * liquidation fee — the duration-free equivalent of the old $4.21-scale sizing. - * Alice holds a dominant 12-lot futures long + an 11-qty perps long (delta - * units: futures 12·1 = 12, perps 11; S = 23). Futures is made the worst leg by - * lot count (each lot now ±1 delta, so its loss must out-number the perps qty). - * Moderate crash 40 → 30: + * Staged at a $40 mark (crash to $30) — the duration-free equivalent of the old + * $4.21-scale sizing. Alice holds a dominant 12-lot futures long + an 11-qty + * perps long (delta units: futures 12·1 = 12, perps 11; S = 23). Futures is made + * the worst leg by lot count (each lot now ±1 delta, so its loss out-numbers the + * perps qty). Moderate crash 40 → 30: * - mmReq(30) = 23·0.05·30 + 11·(40−30) + 12·(40−30) * = 34.50 + 110 + 120 = $264.50 * - imReq(30) = 23·0.10·30 + 230 = 69 + 230 = $299 - * - $250 deposit ⇒ underwater by ~$14.50 (substantial) - * - futures is worst by loss ($120 > $110), so it's reduced first — but fully - * closing all 12 lots realizes $120 of loss + $12 liquidation fee, dropping - * the balance to $118 against a residual perps mmReq of $126.50, so the - * account is STILL under MM (the futures leg can't close the gap alone). - * - the planner then takes a SECOND iteration and reduces the perps leg. Perps - * closes by a *continuous* quantity: it needs δ ≈ (143 − 118 + $1 fee)/3.00 - * ≈ 9 of the 11 qty, landing precisely on the IM boundary (residual perps - * ~2 qty stays open), unlike the discrete futures-lot granularity. + * - $235 deposit (−$1 perps taker fee ⇒ $234 balance) ⇒ underwater by ~$30.50 + * (substantial). * - * Net effect the test asserts: BOTH venues carry liquidation activity in the - * one sweep (futures fully closed, perps partially closed), the account lands in + * The key sizing invariant: closing a delta unit only improves the portfolio + * margin *gap* by the maintenance-margin relief `mmRate·mark = 0.05·30 = $1.50` + * (realizing the loss debits the balance but drops mmReq by the same amount, so + * only the shock-margin term nets out). Liquidation fees are zero in this harness + * (futures taker fee zeroed; no per-lot liquidation fee applied), so: + * - Full futures capacity = 12·$1.50 = $18 < $30.50 deficit ⇒ even closing ALL + * 12 lots leaves the account under MM: the futures leg CANNOT heal it alone. + * - The planner therefore fully closes the futures leg, then takes a SECOND + * iteration on perps. Perps closes by a *continuous* quantity down to the IM + * boundary (deepest close staying at/under IM), reducing ~9.67 of the 11 qty + * and leaving a residual ~1.33-qty long — unlike the discrete futures-lot + * granularity. + * - Total capacity = 23·$1.50 = $34.50 > $30.50, so the account stays + * recoverable (a residual perps long survives — not the bad-debt path). + * + * Net effect the test asserts: BOTH venues carry liquidation activity in the one + * sweep (futures fully closed, perps partially closed), the account lands in * `[MM, IM]`, and it is not fully wiped (the perps leg keeps a residual long). - * The futures taker fee is zeroed (as elsewhere) so the $1/lot open cost doesn't - * shift the sizing. */ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { @@ -783,7 +788,7 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { const entryMark = parseUnits("40", base.config.tokenDecimals); await base.setMark(entryMark); - const aliceDeposit = parseUnits("250", base.config.tokenDecimals); + const aliceDeposit = parseUnits("235", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); const alicePerpsQty = parseUnits("11", base.config.quantityDecimals); const aliceFuturesQty = 12; From ef1e1a1e759aacace8a10a17cab24297d48d864a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 14 Jul 2026 16:28:14 +0200 Subject: [PATCH 085/180] feat: single instance of market maker --- .github/workflows/deploy-col-mar-mm.yml | 123 +++++++-------- market-maker/configs/portfolio.dev.yml | 142 ++++++++++++++++++ market-maker/configs/portfolio.prd.yml | 134 +++++++++++++++++ market-maker/configs/portfolio.stg.yml | 134 +++++++++++++++++ market-maker/docker-entrypoint.sh | 12 +- market-maker/package.json | 2 +- .../src/adapters/futures/instrument.ts | 10 ++ market-maker/src/adapters/perps/instrument.ts | 8 + market-maker/src/core/adapter.ts | 12 ++ market-maker/src/core/txCoordinator.ts | 46 +++++- market-maker/tests/core/txCoordinator.test.ts | 39 ++++- 11 files changed, 576 insertions(+), 86 deletions(-) create mode 100644 market-maker/configs/portfolio.dev.yml create mode 100644 market-maker/configs/portfolio.prd.yml create mode 100644 market-maker/configs/portfolio.stg.yml diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index 5564496..1e64961 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -1,15 +1,19 @@ name: Deploy Collateral Margin Market Maker -# CI/CD owns the "personality" of the perps and futures market-maker ECS -# services. Terraform builds the scaffolding (service shell, ALB, target -# group, log group, security groups, Route53, IAM); this workflow: +# CI/CD owns the "personality" of the single portfolio market-maker ECS +# service. One process quotes perps AND every selected futures expiry on one +# shared signer/collateral vault, so we run it on a single ECS service. We +# reuse the futures service scaffolding (svc/tsk-col-mar-futures-mm) that +# Terraform provisions; the perps service is no longer deployed to. +# Terraform builds the scaffolding (service shell, ALB, target group, log +# group, security groups, Route53, IAM); this workflow: # # 1. Builds one Docker image from market-maker/ and pushes to GHCR -# 2. For each venue (perps, futures), renders a new task-def revision +# 2. Renders a new task-def revision for the portfolio app (MAKER_APP=portfolio) # with image + env vars + secrets sourced from GitHub Variables and # Secrets (no AWS Secrets Manager) -# 3. Calls ecs:UpdateService to point both services at the new revisions -# and to scale them to the operator-chosen desired_count +# 3. Calls ecs:UpdateService to point the service at the new revision +# and to scale it to the operator-chosen desired_count # # GitHub Variables (configure per-environment under Settings -> Environments): # MAKER_LOG_LEVEL default "info" @@ -17,15 +21,14 @@ name: Deploy Collateral Margin Market Maker # MAKER_CANCEL_ORDERS_ON_SHUTDOWN default "true" # MAKER_HEALTH_PORT default "3001" (must match TF cnt_port) # MAKER_DESIRED_COUNT default "1" (set 0 to halt without redeploy) -# PERPS_ADDRESS contract address (perps service only) -# FUTURES_ADDRESS contract address (futures service only) +# PERPS_ADDRESS HashPowerPerpsDEX contract address +# FUTURES_ADDRESS Futures contract address # ETH_PRICE_FEED_ADDRESS optional Chainlink feed # # GitHub Secrets (configure per-environment): # AWS_ROLE_ARN_DEV / _STG / _LMN OIDC role ARNs from the TF output github_actions_role_arn # ALCHEMY_API_KEY shared Alchemy project key (composes the RPC URL) -# PERPS_MM_PRIVATE_KEY perps wallet private key -# FUTURES_MM_PRIVATE_KEY futures wallet private key +# FUTURES_MM_PRIVATE_KEY shared portfolio signer private key (both venues) # SLACK_WEBHOOK_URL (org or repo level) for slack-notify on: @@ -71,12 +74,9 @@ jobs: env_suffix: ${{ steps.env_config.outputs.env_suffix }} aws_region: ${{ steps.env_config.outputs.aws_region }} ecs_cluster: ${{ steps.env_config.outputs.ecs_cluster }} - perps_service: ${{ steps.env_config.outputs.perps_service }} - futures_service: ${{ steps.env_config.outputs.futures_service }} - perps_task_family: ${{ steps.env_config.outputs.perps_task_family }} - futures_task_family: ${{ steps.env_config.outputs.futures_task_family }} - perps_health_url: ${{ steps.env_config.outputs.perps_health_url }} - futures_health_url: ${{ steps.env_config.outputs.futures_health_url }} + service: ${{ steps.env_config.outputs.service }} + task_family: ${{ steps.env_config.outputs.task_family }} + health_url: ${{ steps.env_config.outputs.health_url }} is_cicd_branch: ${{ steps.gen_tag.outputs.is_cicd_branch }} steps: @@ -100,12 +100,12 @@ jobs: run: | ENV="${{ steps.gen_tag.outputs.environment }}" echo "aws_region=us-east-1" >> $GITHUB_OUTPUT - echo "perps_task_family=tsk-col-mar-perps-mm" >> $GITHUB_OUTPUT - echo "futures_task_family=tsk-col-mar-futures-mm" >> $GITHUB_OUTPUT + # The unified portfolio MM runs on the futures service scaffolding. + echo "task_family=tsk-col-mar-futures-mm" >> $GITHUB_OUTPUT # URL_HOST_PREFIX matches the per-env Route53 alias produced by the - # collateral-margin TF stack (perps_mm_endpoint / futures_mm_endpoint - # in 00_outputs.tf): dev/stg get a subdomain, prod uses the apex. + # collateral-margin TF stack (futures_mm_endpoint in 00_outputs.tf): + # dev/stg get a subdomain, prod uses the apex. case "$ENV" in dev) SUFFIX="dev" @@ -131,10 +131,8 @@ jobs: echo "env_suffix=${SUFFIX}" >> $GITHUB_OUTPUT echo "maker_env=${MAKER_ENV}" >> $GITHUB_OUTPUT echo "ecs_cluster=ecs-derivatives-marketplace-${SUFFIX}" >> $GITHUB_OUTPUT - echo "perps_service=svc-col-mar-perps-mm-${SUFFIX}" >> $GITHUB_OUTPUT - echo "futures_service=svc-col-mar-futures-mm-${SUFFIX}" >> $GITHUB_OUTPUT - echo "perps_health_url=https://perpsmm.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT - echo "futures_health_url=https://futuresmm.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT + echo "service=svc-col-mar-futures-mm-${SUFFIX}" >> $GITHUB_OUTPUT + echo "health_url=https://futuresmm.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -187,15 +185,11 @@ jobs: echo "**Image:** Built locally, not pushed to GHCR" >> $GITHUB_STEP_SUMMARY deploy: - name: 🚀 Deploy ${{ matrix.app }} + name: 🚀 Deploy portfolio runs-on: ubuntu-latest needs: build if: needs.build.outputs.is_cicd_branch != 'true' environment: ${{ needs.build.outputs.environment }} - strategy: - fail-fast: false - matrix: - app: [ perps, futures ] steps: - name: Configure AWS credentials @@ -205,13 +199,12 @@ jobs: secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} - role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-${{ github.run_id }} + role-session-name: GitHubActions-ColMarMM-portfolio-${{ github.run_id }} - name: Verify service exists id: svc_check env: - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || - needs.build.outputs.futures_service }} + SERVICE: ${{ needs.build.outputs.service }} CLUSTER: ${{ needs.build.outputs.ecs_cluster }} REGION: ${{ needs.build.outputs.aws_region }} run: | @@ -224,7 +217,7 @@ jobs: if [ "$ACTIVE_COUNT" = "0" ]; then echo "⚠️ ECS service '$SERVICE' is not ACTIVE in cluster '$CLUSTER'." - echo " Likely Terraform has create=false for this venue. Skipping deploy." + echo " Likely Terraform has create=false for this service. Skipping deploy." echo "skip=true" >> $GITHUB_OUTPUT else echo "✅ Service '$SERVICE' is active. Proceeding with deploy." @@ -236,10 +229,8 @@ jobs: env: # Routing CLUSTER: ${{ needs.build.outputs.ecs_cluster }} - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || - needs.build.outputs.futures_service }} - TASK_FAMILY: ${{ matrix.app == 'perps' && needs.build.outputs.perps_task_family - || needs.build.outputs.futures_task_family }} + SERVICE: ${{ needs.build.outputs.service }} + TASK_FAMILY: ${{ needs.build.outputs.task_family }} REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} @@ -252,27 +243,26 @@ jobs: ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} HASHPRICE_ORACLE_SUBGRAPH_URL: ${{ vars.HASHPRICE_ORACLE_SUBGRAPH_URL }} + # Both venue addresses — the portfolio app quotes perps + futures. + PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} + FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} + # Per-environment GitHub Secrets (encrypted personality). Secrets - # only flow into this step's process env via inline ternaries; they - # are never written to step outputs or to disk on the runner. + # only flow into this step's process env; they are never written to + # step outputs or to disk on the runner. One shared signer runs both + # venues; we reuse the futures wallet key. ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} - PRIVATE_KEY: ${{ matrix.app == 'perps' && secrets.PERPS_MM_PRIVATE_KEY || - secrets.FUTURES_MM_PRIVATE_KEY }} - - # Per-venue config selected by matrix.app - VENUE_ADDR_NAME: ${{ matrix.app == 'perps' && 'PERPS_ADDRESS' || 'FUTURES_ADDRESS' }} - VENUE_ADDR_VALUE: ${{ matrix.app == 'perps' && vars.PERPS_ADDRESS || - vars.FUTURES_ADDRESS }} + PRIVATE_KEY: ${{ secrets.FUTURES_MM_PRIVATE_KEY }} # Computed - MAKER_APP: ${{ matrix.app }} + MAKER_APP: portfolio MAKER_ENV: ${{ needs.build.outputs.maker_env }} COMMIT_HASH: ${{ github.sha }} run: | set -euo pipefail DESIRED_COUNT="${MAKER_DESIRED_COUNT:-1}" - echo "🚀 Deploying ${{ matrix.app }} to ${{ needs.build.outputs.environment }}" + echo "🚀 Deploying portfolio to ${{ needs.build.outputs.environment }}" echo " Cluster: ${CLUSTER}" echo " Service: ${SERVICE}" echo " Task Family: ${TASK_FAMILY}" @@ -308,8 +298,8 @@ jobs: --arg ETH_PRICE_FEED_ADDRESS "${ETH_PRICE_FEED_ADDRESS:-}" \ --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ --arg PRIVATE_KEY "${PRIVATE_KEY}" \ - --arg VENUE_ADDR_NAME "${VENUE_ADDR_NAME}" \ - --arg VENUE_ADDR_VALUE "${VENUE_ADDR_VALUE}" \ + --arg PERPS_ADDRESS "${PERPS_ADDRESS}" \ + --arg FUTURES_ADDRESS "${FUTURES_ADDRESS}" \ --arg HASHPRICE_ORACLE_SUBGRAPH_URL "${HASHPRICE_ORACLE_SUBGRAPH_URL}" \ '[ {name:"MAKER_APP", value:$MAKER_APP}, @@ -322,7 +312,8 @@ jobs: {name:"ETH_PRICE_FEED_ADDRESS", value:$ETH_PRICE_FEED_ADDRESS}, {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, {name:"PRIVATE_KEY", value:$PRIVATE_KEY}, - {name:$VENUE_ADDR_NAME, value:$VENUE_ADDR_VALUE}, + {name:"PERPS_ADDRESS", value:$PERPS_ADDRESS}, + {name:"FUTURES_ADDRESS", value:$FUTURES_ADDRESS}, {name:"HASHPRICE_ORACLE_SUBGRAPH_URL", value:$HASHPRICE_ORACLE_SUBGRAPH_URL} ]' > env-block.json @@ -352,17 +343,13 @@ jobs: --region "${REGION}" \ --force-new-deployment > /dev/null - echo "✅ Deployment triggered for ${{ matrix.app }}" + echo "✅ Deployment triggered for portfolio" verify: - name: 🔍 Verify ${{ matrix.app }} + name: 🔍 Verify portfolio runs-on: ubuntu-latest needs: [ build, deploy ] if: needs.build.outputs.is_cicd_branch != 'true' - strategy: - fail-fast: false - matrix: - app: [ perps, futures ] steps: - name: Configure AWS credentials @@ -372,12 +359,11 @@ jobs: secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} - role-session-name: GitHubActions-ColMarMM-${{ matrix.app }}-Verify-${{ github.run_id }} + role-session-name: GitHubActions-ColMarMM-portfolio-Verify-${{ github.run_id }} - name: Wait for service to stabilize env: - SERVICE: ${{ matrix.app == 'perps' && needs.build.outputs.perps_service || - needs.build.outputs.futures_service }} + SERVICE: ${{ needs.build.outputs.service }} CLUSTER: ${{ needs.build.outputs.ecs_cluster }} REGION: ${{ needs.build.outputs.aws_region }} run: | @@ -436,12 +422,12 @@ jobs: run: | echo "## 🎉 Deployment Complete" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Service:** Collateral Margin Market Maker (perps + futures)" >> $GITHUB_STEP_SUMMARY + echo "**Service:** Collateral Margin Market Maker (portfolio: perps + futures)" >> $GITHUB_STEP_SUMMARY echo "**Environment:** ${{ needs.build.outputs.environment }}" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ needs.build.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**Image:** \`${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY echo "**Cluster:** ${{ needs.build.outputs.ecs_cluster }}" >> $GITHUB_STEP_SUMMARY - echo "**Services:** \`${{ needs.build.outputs.perps_service }}\`, \`${{ needs.build.outputs.futures_service }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Service:** \`${{ needs.build.outputs.service }}\`" >> $GITHUB_STEP_SUMMARY - name: Failure summary if: needs.verify.result == 'failure' @@ -493,11 +479,8 @@ jobs: image_tag: ${{ needs.build.outputs.is_cicd_branch != 'true' && format('{0}:{1}', env.GHCR_IMAGE, needs.build.outputs.version) || '' }} additional_info: "${{ needs.build.outputs.is_cicd_branch == 'true' && '*Mode:* - CI/CD Test (build only, no deploy)' || format('*Health:* <{0}|Perps> - <{1}|Futures> - - *Cluster:* `{2}` • *Services:* `{3}`, `{4}`', - needs.build.outputs.perps_health_url, - needs.build.outputs.futures_health_url, - needs.build.outputs.ecs_cluster, needs.build.outputs.perps_service, - needs.build.outputs.futures_service) }}" + CI/CD Test (build only, no deploy)' || format('*Health:* <{0}|Portfolio> + + *Cluster:* `{1}` • *Service:* `{2}`', + needs.build.outputs.health_url, + needs.build.outputs.ecs_cluster, needs.build.outputs.service) }}" diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml new file mode 100644 index 0000000..28cbd5d --- /dev/null +++ b/market-maker/configs/portfolio.dev.yml @@ -0,0 +1,142 @@ +# yaml-language-server: $schema=../schemas/portfolio.json +# Titan Market Maker - Portfolio (perps + all futures expiries) - DEV (base-sepolia). +# +# PRIVATE_KEY - hex private key of the dev market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia +# FUTURES_ADDRESS - Futures address on base-sepolia +# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia +# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill +# +# One process, one signer, one shared collateral vault. Perps and every +# selected futures expiry quote together; the TxCoordinator sequences their +# txs on a single nonce and each market is isolated behind its own circuit +# breaker. + +nodeEnv: development +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} +# Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we +# don't burn gas on cancel-then-reopen across every restart. +cancelOrdersOnShutdown: false + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +# Single shared signer for the whole portfolio. +wallet: primary + +network: + name: base-sepolia + rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venues: + - kind: perps + address: ${PERPS_ADDRESS} + maxPositionSize: 50 + pricing: + # The MM reads the unrounded oracle answer (PerpsVenue.getRawMarketPrice), + # so the mid lands between ticks and tick-rounding alone produces 1-tick + # bid/ask separation. A non-zero floor still buys insurance for the rare + # case `r` lands exactly on a tick. + strategy: effective-spread + minSpreadBps: 0 + volatilityMultiplier: 0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + sizing: + strategy: linear + baseQuantity: "1000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 5 + + - kind: futures + address: ${FUTURES_ADDRESS} + maxPositionSize: 50 + # Quote the three nearest expiries; the roll adds/drops markets as dates mature. + marketSelection: + mode: nearest + count: 3 + pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 0 + volatilityMultiplier: 0 + maxSkewTicks: 0 + sizing: + strategy: geometric-taper + # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, + # and every qty unit becomes a distinct resting order. The quoter spreads + # baseQuantity × numLevelsPerSide across the ladder, so total resting + # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 + # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 + # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. + baseQuantity: 4 # venue-native (contracts base units) + numLevelsPerSide: 3 + taperRatio: 0.6 + +# Shared portfolio-wide budget across every market. +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 10 + requoteThresholdTicks: 2 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: 500 + maxCollateralAmount: 1500 + +oracle: + # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless + # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat + # so backfill returns a full window. + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} + +health: + port: ${MAKER_HEALTH_PORT:-3001} + +# Centralized submission / nonce recovery. +txCoordinator: + # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: + # perps = one order per price level; futures = one contract (qty=1). Futures + # createOrder gas scales with qty, so a full 3-expiry quote is many units and + # must be chunked to avoid out-of-gas. Lowered from 50 for base-sepolia. + maxCallsPerTx: 20 + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 20 diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml new file mode 100644 index 0000000..6b2373f --- /dev/null +++ b/market-maker/configs/portfolio.prd.yml @@ -0,0 +1,134 @@ +# yaml-language-server: $schema=../schemas/portfolio.json +# Titan Market Maker - Portfolio (perps + all futures expiries) - PRODUCTION (base-mainnet). +# +# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) +# FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup +# +# One process, one signer, one shared collateral vault. Perps and every +# selected futures expiry quote together; the TxCoordinator sequences their +# txs on a single nonce and each market is isolated behind its own circuit +# breaker. + +nodeEnv: production +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-info} +dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +# Single shared signer for the whole portfolio. +wallet: primary + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venues: + - kind: perps + address: ${PERPS_ADDRESS} + maxPositionSize: 1000 + pricing: + strategy: effective-spread + minSpreadBps: 10 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + sizing: + strategy: linear + baseQuantity: "10000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 5 + + - kind: futures + address: ${FUTURES_ADDRESS} + maxPositionSize: 1000 + # Quote the three nearest expiries; the roll adds/drops markets as dates mature. + marketSelection: + mode: nearest + count: 3 + pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + sizing: + strategy: geometric-taper + # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, + # and every qty unit becomes a distinct resting order. Total resting + # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 + # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 + # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. + baseQuantity: 4 # venue-native (contracts base units) + numLevelsPerSide: 3 + taperRatio: 0.6 + +# Shared portfolio-wide budget across every market. +risk: + maxPositionSize: 1000 + maxUtilizationPct: 75 + minCollateralBalance: 100 + maxDailyLossUsd: 1000 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 3 + requoteThresholdTicks: 2 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: 1 + maxCollateralAmount: 5000 + +oracle: + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + +health: + port: ${MAKER_HEALTH_PORT:-3001} + +# Centralized submission / nonce recovery. +txCoordinator: + # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: + # perps = one order per price level; futures = one contract (qty=1). Futures + # createOrder gas scales with qty, so a full 3-expiry quote is many units and + # must be chunked to avoid out-of-gas. + maxCallsPerTx: 20 + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 20 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml new file mode 100644 index 0000000..d340040 --- /dev/null +++ b/market-maker/configs/portfolio.stg.yml @@ -0,0 +1,134 @@ +# yaml-language-server: $schema=../schemas/portfolio.json +# Titan Market Maker - Portfolio (perps + all futures expiries) - STAGING (base-mainnet). +# +# PRIVATE_KEY - hex private key of the staging market-making wallet +# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) +# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) +# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) +# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup +# +# One process, one signer, one shared collateral vault. Perps and every +# selected futures expiry quote together; the TxCoordinator sequences their +# txs on a single nonce and each market is isolated behind its own circuit +# breaker. + +nodeEnv: staging +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +dryRun: ${MAKER_DRY_RUN:-false} +# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys +# where you'd rather absorb the brief stale-quote risk than pay cancel gas. +cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +# Single shared signer for the whole portfolio. +wallet: primary + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venues: + - kind: perps + address: ${PERPS_ADDRESS} + maxPositionSize: 50 + pricing: + strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + sizing: + strategy: linear + baseQuantity: "1000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 5 + + - kind: futures + address: ${FUTURES_ADDRESS} + maxPositionSize: 50 + # Quote the three nearest expiries; the roll adds/drops markets as dates mature. + marketSelection: + mode: nearest + count: 3 + pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + sizing: + strategy: geometric-taper + # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, + # and every qty unit becomes a distinct resting order. Total resting + # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 + # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 + # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. + baseQuantity: 4 # venue-native (contracts base units) + numLevelsPerSide: 3 + taperRatio: 0.6 + +# Shared portfolio-wide budget across every market. +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 3 + requoteThresholdTicks: 2 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + +collateral: + autoDeposit: true + autoDepositMinAmount: 1 + maxCollateralAmount: 2000 + +oracle: + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + history: + subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} + +health: + port: ${MAKER_HEALTH_PORT:-3001} + +# Centralized submission / nonce recovery. +txCoordinator: + # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: + # perps = one order per price level; futures = one contract (qty=1). Futures + # createOrder gas scales with qty, so a full 3-expiry quote is many units and + # must be chunked to avoid out-of-gas. + maxCallsPerTx: 20 + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 20 diff --git a/market-maker/docker-entrypoint.sh b/market-maker/docker-entrypoint.sh index 2b57ff8..ad5ccd1 100755 --- a/market-maker/docker-entrypoint.sh +++ b/market-maker/docker-entrypoint.sh @@ -2,17 +2,18 @@ # Container entrypoint. # # Required: -# MAKER_APP - "perps" or "futures" +# MAKER_APP - "perps", "futures", or "portfolio" +# ("portfolio" runs perps + all futures expiries in one process) # # Config selection (in precedence order): -# 1. CLI arg: docker run … perps --config /custom/path.yml +# 1. CLI arg: docker run … portfolio --config /custom/path.yml # 2. MAKER_CONFIG env var # 3. MAKER_ENV env var → /app/configs/${MAKER_APP}.${MAKER_ENV}.yml # (MAKER_ENV defaults to "prd" inside containers) set -eu if [ -z "${MAKER_APP:-}" ]; then - echo "MAKER_APP must be set to 'perps' or 'futures'" >&2 + echo "MAKER_APP must be set to 'perps', 'futures', or 'portfolio'" >&2 exit 1 fi @@ -23,8 +24,11 @@ case "$MAKER_APP" in futures) ENTRY="/app/src/apps/futures/main.ts" ;; + portfolio) + ENTRY="/app/src/apps/portfolio/main.ts" + ;; *) - echo "Unknown MAKER_APP='$MAKER_APP' (expected 'perps' or 'futures')" >&2 + echo "Unknown MAKER_APP='$MAKER_APP' (expected 'perps', 'futures', or 'portfolio')" >&2 exit 1 ;; esac diff --git a/market-maker/package.json b/market-maker/package.json index 31d2cb0..560dec9 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -57,4 +57,4 @@ "secp256k1@4.0.4" ] } -} +} \ No newline at end of file diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 5dfed27..6c214be 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -155,6 +155,16 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { }); } + /** + * Cost units = qty. A futures `createOrder(…, int8 qty)` does one unit of + * work per contract, so its gas scales with qty (a qty=1 create ≈ one + * cancel). This lets the shared TxCoordinator budget futures batches by total + * qty rather than call count — the same weighting `chunkCalls` uses below. + */ + createCallWeight(intent: OrderIntent): number { + return Number(intent.size); + } + /** * Execute cancels then creates for this expiry. Kept for single-market * callers and tests; the portfolio runner routes through the shared diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 18720c4..e1af7ca 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -84,6 +84,14 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { }); } + /** + * One cost unit per order: a perps `createOrder` is a single price-level + * insertion whose gas is independent of the order's size. + */ + createCallWeight(_intent: OrderIntent): number { + return 1; + } + /** * Execute cancels then creates on-chain. Perps uses individual * cancelOrder / createOrder calls (no batch functions on the contract). diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index e6eaf50..162a2f9 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -306,6 +306,18 @@ export interface InstrumentAdapter { encodeCreate(intent: OrderIntent): `0x${string}`; encodeCancel(intent: CancelIntent): `0x${string}`; + /** + * Relative gas weight of placing this create, in "cost units" where one unit + * is roughly the cheapest single call. The shared `TxCoordinator` sums these + * against one per-tx budget when chunking a venue batch, so venues with very + * different per-call gas profiles share one limiter: + * - Perps: 1 per order — a create is one price-level insertion. + * - Futures: `size` (qty) — `createOrder(…, int8 qty)` does one unit of + * work per contract, so gas scales with total qty, not calls. + * Cancels are always weight 1 (the coordinator assumes this). + */ + createCallWeight(intent: OrderIntent): number; + /** * Execute a batch of order cancellations and creations on-chain. * diff --git a/market-maker/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts index 89e747f..22652bd 100644 --- a/market-maker/src/core/txCoordinator.ts +++ b/market-maker/src/core/txCoordinator.ts @@ -15,7 +15,14 @@ export interface MarketIntents { } export interface TxCoordinatorConfig { - /** Max encoded calls per on-chain tx before chunking. Default 50. */ + /** + * Max cost units per on-chain tx before chunking. One unit is the cheapest + * single call (see `InstrumentAdapter.createCallWeight`): for perps that's + * one order per price level; for futures it's one contract (qty=1), since a + * futures `createOrder` does one unit of work per qty and its gas scales with + * total qty rather than call count. A single call heavier than the budget is + * still sent alone (it can't be split). Default 50. + */ maxCallsPerTx?: number; } @@ -101,21 +108,26 @@ export class TxCoordinator { // 3. Build + submit per venue, isolated. for (const [venue, markets] of byVenue) { - const calls: `0x${string}`[] = []; + // Each call carries a cost weight so one shared per-tx budget can chunk + // venues with very different per-call gas (perps: 1/level; futures: qty). + const calls: WeightedCall[] = []; let cancelCount = 0; let placeCount = 0; // Cancels first (all markets), then creates (all markets). for (const m of markets) { for (const c of m.cancels) { - calls.push(m.instrument.encodeCancel(c)); + calls.push({ data: m.instrument.encodeCancel(c), weight: 1 }); cancelCount++; } } if (allowCreates) { for (const m of markets) { for (const c of m.creates) { - calls.push(m.instrument.encodeCreate(c)); + calls.push({ + data: m.instrument.encodeCreate(c), + weight: Math.max(1, m.instrument.createCallWeight(c)), + }); placeCount++; } } @@ -158,11 +170,31 @@ export class TxCoordinator { return result; } - private chunk(calls: `0x${string}`[]): `0x${string}`[][] { + /** + * Pack calls into chunks whose summed weight stays within `maxCallsPerTx`. + * A single call heavier than the budget occupies its own chunk (it can't be + * split), so the invariant is "at most one over-budget call per chunk". + */ + private chunk(calls: WeightedCall[]): `0x${string}`[][] { const out: `0x${string}`[][] = []; - for (let i = 0; i < calls.length; i += this.maxCallsPerTx) { - out.push(calls.slice(i, i + this.maxCallsPerTx)); + let current: `0x${string}`[] = []; + let weight = 0; + for (const c of calls) { + if (current.length > 0 && weight + c.weight > this.maxCallsPerTx) { + out.push(current); + current = []; + weight = 0; + } + current.push(c.data); + weight += c.weight; } + if (current.length > 0) out.push(current); return out; } } + +/** An encoded call tagged with its relative gas cost (see createCallWeight). */ +interface WeightedCall { + data: `0x${string}`; + weight: number; +} diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts index e4d7d35..17a61b5 100644 --- a/market-maker/tests/core/txCoordinator.test.ts +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -46,7 +46,7 @@ function makeMarket( venue: VenueAdapter, id: string, cancels: string[], - creates: { price: bigint; im: bigint }[], + creates: { price: bigint; im: bigint; size?: bigint }[], ): MarketIntents { const instrument = { id, @@ -55,11 +55,13 @@ function makeMarket( encodeCreate: (o: { price: bigint }) => `0xO${o.price.toString()}` as `0x${string}`, estimateOrderMargin: (o: { price: bigint }) => creates.find((c) => c.price === o.price)?.im ?? 0n, + // Mirror the futures weighting (cost units = qty) so chunking is exercised. + createCallWeight: (o: { size: bigint }) => Number(o.size), } as unknown as InstrumentAdapter; return { instrument, cancels: cancels.map((o) => ({ orderId: o as `0x${string}` })), - creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: 1n })), + creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: c.size ?? 1n })), }; } @@ -133,7 +135,7 @@ describe("TxCoordinator", () => { assert.equal(futures.batches.length, 1); }); - it("chunks a venue batch that exceeds maxCallsPerTx", async () => { + it("chunks a venue batch that exceeds maxCallsPerTx (unit-weight cancels)", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); const { venue, batches } = makeVenue("futures"); @@ -141,10 +143,39 @@ describe("TxCoordinator", () => { makeMarket(venue, "f1", ["0xa", "0xb", "0xc", "0xd", "0xe"], []), ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - assert.equal(submitCount(), 3); // 5 calls / 2 = 3 chunks + assert.equal(submitCount(), 3); // 5 cancels @ weight 1 / budget 2 = 3 chunks assert.deepEqual(batches.map((b) => b.length), [2, 2, 1]); }); + it("chunks by weighted cost units (futures qty), not raw call count", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, { maxCallsPerTx: 10 }, makeLogger()); + const { venue, batches } = makeVenue("futures"); + // Weights 6, 6, 3: budget 10 → [6] | [6, 3]. Three calls, but two txs. + const markets = [ + makeMarket(venue, "f1", [], [ + { price: 1n, im: 0n, size: 6n }, + { price: 2n, im: 0n, size: 6n }, + { price: 3n, im: 0n, size: 3n }, + ]), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 2); + assert.deepEqual(batches.map((b) => b.length), [1, 2]); + }); + + it("sends a single over-budget call alone rather than dropping it", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); + const { venue, batches } = makeVenue("futures"); + // One create of qty 5 > budget 2 → its own chunk; a trailing cancel packs after. + const markets = [makeMarket(venue, "f1", ["0xz"], [{ price: 1n, im: 0n, size: 5n }])]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + // cancel (w1) then create (w5): [0xCz] fills to 1, +5 > 2 → flush, [0xO1] alone. + assert.equal(submitCount(), 2); + assert.deepEqual(batches.map((b) => b.length), [1, 1]); + }); + it("dry run submits nothing but reports intended counts", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, {}, makeLogger()); From 8d0b097c9e48f3264203c54131f6fe9597688f62 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 14 Jul 2026 16:29:51 +0200 Subject: [PATCH 086/180] fix: update expected order count logic for exact-matching venues and add tests for qty-expanded count deficit --- market-maker/src/core/orderExecutor.ts | 10 ++++- market-maker/tests/core/orderExecutor.test.ts | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 8b4f53e..24f245d 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -204,7 +204,15 @@ export class OrderExecutor { return false; } - const expectedCount = desired.length; + // `ownOrders.size` counts individual resting orders. On exact-matching + // venues (futures) a single createOrder(qty=N) rests as N distinct orders, + // so the comparable "expected" is the qty-expanded total, not the level + // count — otherwise this fast-path is dead (actual is always ≫ levels) and + // the log is misleading. Limit venues (perps) rest one order per level. + const expectedCount = + this.instrument.book.matchingMode === "exact" + ? desired.reduce((sum, i) => sum + Number(i.size), 0) + : desired.length; const actualCount = this.book.ownOrders.size; if (actualCount < expectedCount) { this.logger.debug( diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 52e6b64..0104afe 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -231,6 +231,50 @@ describe("OrderExecutor requote guards (regression)", () => { }); }); +// ── exact-mode (futures) qty-expanded count deficit ──────────────────────── + +describe("OrderExecutor exact-mode count deficit (qty-expanded)", () => { + /** + * On exact-matching venues a createOrder(qty=N) rests as N distinct orders, + * so `expectedCount` must be the qty-expanded total (Σ desired sizes), not + * the level count. A fully-provisioned multi-contract book must NOT churn. + */ + it("does not requote when a multi-contract book is fully provisioned", () => { + const deps = makeDeps(); // matchingMode defaults to "exact" + const executor = makeExecutor(deps); + + // Desired: 3 contracts @95 (buy), 3 @96 (sell). Each contract rests as a + // separate qty=1 order, so seed 3 + 3 individual orders. + for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 1, "buy", 95_000_000n, 1n); + for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 10, "sell", 96_000_000n, 1n); + + executor.recordRequote(0, 0); // anchor mid → drift 0 + const planned = executor.plan([desiredBuy(95_000_000n, 3n), desiredSell(96_000_000n, 3n)]); + assert.equal(planned, null, "no churn: 6 resting orders == 6 desired contracts"); + }); + + /** + * When individual resting orders fall below the qty-expanded desired total + * (a partial fill on an exact venue), the deficit fast-path fires and the + * missing contracts are topped up — the pre-fix level-count comparison + * (2 desired levels vs 5 resting orders) would have missed this. + */ + it("requotes when resting contracts fall below the desired qty total", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + // Only 2 of the 3 desired buy contracts remain (one filled); asks intact. + for (let i = 0; i < 2; i++) seedOrder(deps.book, i + 1, "buy", 95_000_000n, 1n); + for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 10, "sell", 96_000_000n, 1n); + + executor.recordRequote(0, 0); + const planned = executor.plan([desiredBuy(95_000_000n, 3n), desiredSell(96_000_000n, 3n)]); + assert.ok(planned, "requote triggered by qty-expanded count deficit"); + assert.equal(planned.creates.length, 1, "tops up the single missing buy contract"); + assert.equal(planned.creates[0].size, 1n); + }); +}); + // ── plan() / cooldown / gas-spike deferral ───────────────────────────────── describe("OrderExecutor.plan", () => { From 7fc8b7af46eddaaccee05888c38087c4de5f3677 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 15 Jul 2026 16:04:25 +0200 Subject: [PATCH 087/180] fix(market-maker): tolerate shared-signer nonce contention The portfolio market maker shares a signing wallet with the keeper, so a third party can advance the account nonce out from under an in-flight tx, producing endless "nonce too low" failures and zero successful submissions. NonceManager now detects nonce-desync errors (too low / already used / too high) and re-reads the live chain nonce, retrying at the fresh nonce with the base fee instead of fee-bumping or cancelling a spent nonce. Retries are bounded by a new txCoordinator.maxNonceResyncs (default 5); on exhaustion it resets and throws immediately so the failure surfaces to the circuit breaker and the next poll tick retries cleanly. Same-nonce paths are preserved: confirmation-timeout RBF and "replacement transaction underpriced" still keep the nonce and bump the fee. --- market-maker/schemas/portfolio.json | 18 ++- market-maker/src/apps/portfolio/config.ts | 9 ++ market-maker/src/apps/portfolio/main.ts | 1 + market-maker/src/core/nonceManager.ts | 93 +++++++++++++- market-maker/tests/core/nonceManager.test.ts | 114 +++++++++++++++++- market-maker/tests/core/oracleTracker.test.ts | 1 + 6 files changed, 233 insertions(+), 3 deletions(-) diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 9ebdf9a..8b1ddc9 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -1212,7 +1212,8 @@ "maxCallsPerTx", "confirmationTimeoutSec", "maxReplacements", - "replacementFeeBumpPct" + "replacementFeeBumpPct", + "maxNonceResyncs" ], "properties": { "maxCallsPerTx": { @@ -1291,6 +1292,21 @@ ], "description": "Fee bump per replacement attempt, percent.", "default": 15 + }, + "maxNonceResyncs": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Per-submit retries that re-read the chain nonce when another party (e.g. a keeper sharing this wallet) advances it. Workaround for a shared signer.", + "default": 5 } } }, diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts index aaf246c..ce6d737 100644 --- a/market-maker/src/apps/portfolio/config.ts +++ b/market-maker/src/apps/portfolio/config.ts @@ -124,6 +124,12 @@ const txCoordinatorSchema = Type.Object( default: 15, description: "Fee bump per replacement attempt, percent.", }), + maxNonceResyncs: Type.Integer({ + minimum: 0, + default: 5, + description: + "Per-submit retries that re-read the chain nonce when another party (e.g. a keeper sharing this wallet) advances it. Workaround for a shared signer.", + }), }, { ...Closed, default: {}, description: "Centralized submission / nonce recovery." }, ); @@ -252,6 +258,7 @@ export interface ParsedTxCoordinatorConfig { confirmationTimeoutMs: number; maxReplacements: number; replacementFeeBumpPct: number; + maxNonceResyncs: number; } export interface ParsedCircuitBreakerConfig { @@ -320,6 +327,7 @@ export function loadPortfolioConfig( confirmationTimeoutSec: 60, maxReplacements: 2, replacementFeeBumpPct: 15, + maxNonceResyncs: 5, }; const cb = raw.circuitBreaker ?? { quarantineThreshold: 3, @@ -338,6 +346,7 @@ export function loadPortfolioConfig( confirmationTimeoutMs: secondsToMs(tx.confirmationTimeoutSec, "txCoordinator.confirmationTimeoutSec"), maxReplacements: tx.maxReplacements, replacementFeeBumpPct: tx.replacementFeeBumpPct, + maxNonceResyncs: tx.maxNonceResyncs ?? 5, }, circuitBreaker: { quarantineThreshold: cb.quarantineThreshold, diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index dc6ce68..94aacb7 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -266,6 +266,7 @@ async function main(): Promise { confirmationTimeoutMs: config.txCoordinator.confirmationTimeoutMs, maxReplacements: config.txCoordinator.maxReplacements, replacementFeeBumpPct: config.txCoordinator.replacementFeeBumpPct, + maxNonceResyncs: config.txCoordinator.maxNonceResyncs, }, logger, ); diff --git a/market-maker/src/core/nonceManager.ts b/market-maker/src/core/nonceManager.ts index 9216997..48240b8 100644 --- a/market-maker/src/core/nonceManager.ts +++ b/market-maker/src/core/nonceManager.ts @@ -8,6 +8,16 @@ export interface NonceManagerConfig { maxReplacements?: number; /** Fee bump per replacement attempt, in percent. Default 15%. */ replacementFeeBumpPct?: number; + /** + * Max times, within a single submit, to re-read the chain nonce and retry when + * a *third party* advanced the nonce out from under us (e.g. a keeper sharing + * this wallet). Guards against the pathological "nonce too low" thrash when the + * signer is not exclusively owned by this process. Default 5. + * + * NOTE: This is a resilience workaround for a shared signer. The correct fix is + * a dedicated wallet per process — a single EOA nonce cannot be safely shared. + */ + maxNonceResyncs?: number; } /** Broadcasts one logical tx at the given nonce/fee and returns its hash. */ @@ -43,6 +53,7 @@ export class NonceManager { private readonly confirmationTimeoutMs: number; private readonly maxReplacements: number; private readonly bumpPct: number; + private readonly maxNonceResyncs: number; private readonly publicClient: PublicClient; private readonly walletClient: WalletClient; @@ -65,6 +76,7 @@ export class NonceManager { this.confirmationTimeoutMs = cfg.confirmationTimeoutMs ?? 60_000; this.maxReplacements = cfg.maxReplacements ?? 2; this.bumpPct = cfg.replacementFeeBumpPct ?? 15; + this.maxNonceResyncs = cfg.maxNonceResyncs ?? 5; this.logger = logger.child({ component: "nonce" }); } @@ -106,8 +118,9 @@ export class NonceManager { broadcast: Broadcast, opts: { maxFeePerGas: bigint; label: string }, ): Promise { - const nonce = await this.nextNonce(); + let nonce = await this.nextNonce(); let fee = opts.maxFeePerGas; + let resyncs = 0; for (let attempt = 0; attempt <= this.maxReplacements; attempt++) { try { @@ -127,6 +140,34 @@ export class NonceManager { "tx confirmation timed out; replacing by fee", ); } catch (err) { + // A *third party* (e.g. a keeper sharing this wallet) consumed our nonce. + // Fee-bumping or cancelling a nonce that is already spent is pointless and + // only burns gas, so re-read the live nonce and retry at the fresh value. + if (isNonceDesyncError(err)) { + // Exhausted the resync budget: the nonce is being taken faster than we + // can claim it. A spent nonce cannot be replaced or cancelled, so skip + // the fee-bump/cancel escalation entirely and surface the failure now — + // the next poll tick retries with a freshly re-read nonce. + if (resyncs >= this.maxNonceResyncs) { + this.resetNonce(); + this.logger.error( + { err, label: opts.label, nonce, resyncs }, + "nonce repeatedly advanced by another party; giving up this cycle", + ); + throw err instanceof Error ? err : new Error(String(err)); + } + resyncs++; + this.resetNonce(); + const fresh = await this.nextNonce(); + this.logger.warn( + { label: opts.label, staleNonce: nonce, freshNonce: fresh, resyncs }, + "nonce advanced by another party; resyncing to chain", + ); + nonce = fresh; + fee = opts.maxFeePerGas; // fresh nonce starts from the base fee again + attempt = -1; // ...becomes 0 after the loop increment: full retry budget + continue; + } // A submission error (revert-on-send, RPC error). Fee-bump-and-retry a // couple of times; a persistent failure likely means the nonce is // wedged, so unstick it below. @@ -192,3 +233,53 @@ export class NonceManager { } } } + +/** + * Substrings that mean the nonce we used no longer matches the chain and we must + * move to a *fresh* nonce (someone else advanced this wallet's nonce, or we left + * a gap). Deliberately EXCLUDES same-nonce replacement signals like "replacement + * transaction underpriced" and "already known": those mean we still own the nonce + * and should keep it while bumping the fee, so they fall through to the RBF path. + */ +const NONCE_DESYNC_PATTERNS = [ + "nonce too low", + "lower than the current nonce", + "nonce too high", + "nonce has already been used", + "invalid nonce", + "oldnonce", + "noncetoolow", + "noncetoohigh", +] as const; + +/** + * True when `err` (or anything in its `cause` chain) means the nonce we used is + * stale relative to the chain — i.e. the tx needs a *new* nonce, not a fee bump. + * Matches viem's `NonceTooLowError`/`NonceTooHighError` and raw RPC messages. + * + * Returns false for replacement-underpriced / already-known errors: those keep + * the same nonce and are handled by the fee-bump replacement path. + */ +export function isNonceDesyncError(err: unknown): boolean { + const seen = new Set(); + let cur: unknown = err; + while (cur && typeof cur === "object" && !seen.has(cur)) { + seen.add(cur); + const e = cur as { + name?: unknown; + message?: unknown; + shortMessage?: unknown; + details?: unknown; + cause?: unknown; + }; + const haystack = [e.name, e.message, e.shortMessage, e.details] + .filter((v): v is string => typeof v === "string") + .join(" | ") + .toLowerCase(); + if (NONCE_DESYNC_PATTERNS.some((p) => haystack.includes(p))) { + return true; + } + cur = e.cause; + } + return false; +} diff --git a/market-maker/tests/core/nonceManager.test.ts b/market-maker/tests/core/nonceManager.test.ts index 60a8e6e..516c0b3 100644 --- a/market-maker/tests/core/nonceManager.test.ts +++ b/market-maker/tests/core/nonceManager.test.ts @@ -167,7 +167,8 @@ describe("NonceManager", () => { const attempts: { nonce: number; fee: bigint }[] = []; const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { attempts.push({ nonce, fee: maxFeePerGas }); - if (attempts.length === 1) return Promise.reject(new Error("nonce too low")); + // A non-nonce transient error (RPC hiccup) → fee-bump + retry same nonce. + if (attempts.length === 1) return Promise.reject(new Error("429 Too Many Requests")); return Promise.resolve("0xok" as const); }; @@ -179,6 +180,117 @@ describe("NonceManager", () => { assert.equal(mocks.cancelCalls.length, 0, "no cancel-tx for a recovered submit"); }); + it("resyncs to the chain nonce (no fee-bump, no cancel) when another party advances it", async () => { + // A keeper sharing this wallet consumes nonce 100 before our broadcast lands, + // so the first send is rejected "nonce too low". We must re-read the chain and + // retry at the fresh nonce — NOT fee-bump a spent nonce or send a cancel-tx. + let reads = 0; + const mocks = makeMocks({ startNonce: 100, receiptFor: async () => RECEIPT }); + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + reads++; + return reads === 1 ? 100 : 101; // chain advanced by the other party + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 2, replacementFeeBumpPct: 20, maxNonceResyncs: 5 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + if (attempts.length === 1) { + return Promise.reject( + new Error("Nonce provided for the transaction (100) is lower than the current nonce"), + ); + } + return Promise.resolve("0xok" as const); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "shared" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.deepEqual( + attempts.map((a) => a.nonce), + [100, 101], + "retried at the fresh chain nonce", + ); + assert.equal(attempts[1].fee, 100n, "fee reset to base for the fresh nonce (not bumped)"); + assert.equal(mocks.cancelCalls.length, 0, "no cancel-tx for a nonce someone else spent"); + }); + + it("keeps the same nonce and bumps fee on 'replacement transaction underpriced'", async () => { + // A same-nonce replacement that was under-bumped must retry the SAME nonce + // with a higher fee — it must NOT be treated as a stolen nonce and resynced. + let reads = 0; + const mocks = makeMocks({ startNonce: 42, receiptFor: async () => RECEIPT }); + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + reads++; + return 42; // if this were wrongly treated as desync, a re-read would happen + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 2, replacementFeeBumpPct: 20 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + if (attempts.length === 1) { + return Promise.reject(new Error("replacement transaction underpriced")); + } + return Promise.resolve("0xok" as const); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "rbf" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.deepEqual( + attempts.map((a) => a.nonce), + [42, 42], + "same nonce reused for the replacement", + ); + assert.equal(attempts[1].fee, 120n, "fee bumped +20% for the replacement"); + assert.equal(reads, 1, "no nonce re-read for a same-nonce replacement"); + assert.equal(mocks.cancelCalls.length, 0); + }); + + it("gives up after maxNonceResyncs when the nonce keeps getting stolen", async () => { + const mocks = makeMocks({ startNonce: 5, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 0, maxNonceResyncs: 2 }, + makeLogger(), + ); + + let calls = 0; + const broadcast = () => { + calls++; + return Promise.reject(new Error("nonce too low")); + }; + await assert.rejects( + nm.submit(broadcast, { maxFeePerGas: 1n, label: "contended" }), + /nonce too low/, + ); + // 1 initial + 2 resyncs = 3 broadcast attempts, then throw immediately. + assert.equal(calls, 3, "bounded by maxNonceResyncs"); + assert.equal( + mocks.cancelCalls.length, + 0, + "no cancel-tx: a nonce someone else spent cannot be cancelled", + ); + }); + it("escalates to a cancel-tx and re-reads the nonce after a persistent send error", async () => { const mocks = makeMocks({ startNonce: 2, receiptFor: async () => RECEIPT }); let counts = 0; diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index 550e7a5..54444d8 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -38,6 +38,7 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { executeOrders: async () => ({ receipts: [], errors: [] }), estimateOrderMargin: () => 0n, estimateCreateGas: async () => 0n, + createCallWeight: () => 1, }; } From aec618dc052ef86f0ad1053f3708ff592f4f8686 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 15:54:47 +0200 Subject: [PATCH 088/180] feat(keeper,mm): retarget Futures venue to 3.0 aggregate API Update keeper prediction/liquidation/delivery paths and futures MM adapters for unilateral (user, deliveryAt) positions and qty-bearing orders. Point futures deps at the futures-marketplace 3.0 PR branch (retarget to #dev after that PR merges). Co-authored-by: Cursor --- docs/points-system-design.md | 2 +- keeper/package.json | 4 +- keeper/pnpm-lock.yaml | 11 +- keeper/pnpm-workspace.yaml | 1 - keeper/scripts/audit-indexer-sync.ts | 8 +- keeper/scripts/debug-delivery-bootstrap.ts | 88 +- keeper/src/config.ts | 15 +- keeper/src/delivery/coordinator.ts | 687 +++-------- keeper/src/discovery/tracker.ts | 30 +- keeper/src/index.ts | 4 +- keeper/src/predict/mm.ts | 17 +- keeper/src/predict/snapshot.ts | 37 +- keeper/src/predict/solve.ts | 240 ++-- keeper/src/predict/types.ts | 34 +- keeper/src/runtime/outdatedOrderSweeper.ts | 12 +- keeper/src/venues/futures.ts | 120 +- keeper/src/venues/types.ts | 6 +- keeper/tests/delivery/coordinator.test.ts | 1003 ++++------------- keeper/tests/discovery/tracker.test.ts | 4 +- keeper/tests/integration/deployStack.ts | 4 +- keeper/tests/integration/helpers.ts | 112 +- .../integration/keeper.integration.test.ts | 205 ++-- keeper/tests/integration/scenarios.ts | 18 +- keeper/tests/predict/coordinator.test.ts | 2 +- .../tests/predict/coordinatorAlerts.test.ts | 2 +- keeper/tests/predict/mm.test.ts | 16 +- keeper/tests/predict/snapshot.test.ts | 74 +- keeper/tests/predict/solve.test.ts | 2 +- keeper/tests/predict/solveTarget.test.ts | 269 ++--- .../runtime/outdatedOrderSweeper.test.ts | 12 +- keeper/tests/venues/futures.test.ts | 91 +- keeper/tests/venues/reduceToTarget.test.ts | 234 ++-- market-maker/package.json | 2 +- market-maker/pnpm-lock.yaml | 36 +- market-maker/src/adapters/futures/events.ts | 58 +- .../src/adapters/futures/instrument.ts | 83 +- .../src/adapters/futures/ownOrders.ts | 18 +- 37 files changed, 1192 insertions(+), 2369 deletions(-) diff --git a/docs/points-system-design.md b/docs/points-system-design.md index 81bcb06..9abaf47 100644 --- a/docs/points-system-design.md +++ b/docs/points-system-design.md @@ -43,7 +43,7 @@ Explicitly **not** rewarded: Both venues distinguish maker and taker on-chain: - perps: the `OrderMatched` event carries `maker`, `taker`, and separate `makerFee` / `takerFee`. -- futures: `LotCreated` / `LotTransferred` carry `makerOrderId` and `takerOrderId`. +- futures (3.0): `OrderMatched` carries `maker`, `taker`, `makerFee` / `takerFee` (same shape as perps, plus `deliveryAt`). This lets the points hook (Section 5) apply different weights to each side without any off-chain inference. diff --git a/keeper/package.json b/keeper/package.json index 93ad925..9a93177 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, @@ -32,4 +32,4 @@ "pino-pretty": "^13.1.3" }, "packageManager": "pnpm@11.1.1" -} \ No newline at end of file +} diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index d1efb4f..5c1b891 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -7,7 +7,6 @@ settings: patchedDependencies: collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d - futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a importers: @@ -23,8 +22,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,8 +165,8 @@ 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/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} version: 0.0.0 help-me@5.0.0: @@ -364,7 +363,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index 7064f91..ec4c4f0 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -3,4 +3,3 @@ blockExoticSubdeps: false patchedDependencies: collateral-margin-abi: patches/collateral-margin-abi@.patch derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch - futures-marketplace-abi: patches/futures-marketplace-abi@.patch diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts index 684f942..e6145b4 100644 --- a/keeper/scripts/audit-indexer-sync.ts +++ b/keeper/scripts/audit-indexer-sync.ts @@ -1,5 +1,5 @@ /** - * Audit indexer netQuantityAfter against on-chain getPositionIds count. + * Audit indexer netQuantityAfter against on-chain getActiveDeliveryDates count. * Finds the first block where indexer and chain diverge. * * Run: @@ -77,11 +77,11 @@ async function getChainPositionCount(blockNumber: number): Promise { const ids = await client.readContract({ address: FUT, abi: FuturesAbi, - functionName: "getPositionIds", + functionName: "getActiveDeliveryDates", args: [USER as Address], blockNumber: BigInt(blockNumber), }); - return (ids as readonly Hex[]).length; + return (ids as readonly bigint[]).length; } function sleep(ms: number) { @@ -167,7 +167,7 @@ async function main() { console.log("First divergence at block %d:", firstMismatch.block); console.log(" tx: %s", firstMismatch.tx); console.log(" indexer netQuantityAfter (abs): %d", firstMismatch.indexer); - console.log(" chain getPositionIds().length: %d", firstMismatch.chain); + console.log(" chain getActiveDeliveryDates().length: %d", firstMismatch.chain); } else { console.log("No divergence detected — indexer and chain are in sync."); } diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index b3ef811..e53a2c8 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -1,14 +1,11 @@ /** * One-off diagnostic: replicate `DeliveryCoordinator.bootstrapFromUsers` - * against the live RPC for a hard-coded user list, printing exactly what - * `multicall` returns at each stage. Lets us tell whether discovery is - * silently no-oping (returns []) vs throwing (caught somewhere) vs - * returning data we then fail to index. + * against the live RPC for a hard-coded user list. * * Run with: * pnpm node --env-file=../.env scripts/debug-delivery-bootstrap.ts */ -import { createPublicClient, http, type Address, type Hex } from "viem"; +import { createPublicClient, http, type Address } from "viem"; import { baseSepolia, base, hardhat } from "viem/chains"; import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; @@ -23,8 +20,6 @@ const RPC_URL = `https://${NETWORK}.g.alchemy.com/v2/${ALCHEMY}`; const CHAINS = { "base-sepolia": baseSepolia, "base-mainnet": base, hardhat }; const chain = CHAINS[NETWORK as keyof typeof CHAINS]; -// Hard-coded list mirroring the production tracker.list() output. -// Edit if you want to test different users. const USERS: Address[] = ["0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4"]; const client = createPublicClient({ chain, transport: http(RPC_URL) }); @@ -32,39 +27,42 @@ const client = createPublicClient({ chain, transport: http(RPC_URL) }); console.log("RPC:", RPC_URL.replace(ALCHEMY, "***")); console.log("FUTURES:", FUTURES); console.log("USERS:", USERS); -console.log("multicall3 configured:", chain.contracts?.multicall3?.address); -console.log("\n--- Stage 1: getPositionIds via multicall ---"); -const idLists = await client.multicall({ +console.log("\n--- Stage 1: getActiveDeliveryDates via multicall ---"); +const dateLists = await client.multicall({ contracts: USERS.map((u) => ({ address: FUTURES, abi: FuturesAbi, - functionName: "getPositionIds" as const, + functionName: "getActiveDeliveryDates" as const, args: [u] as const, })), allowFailure: false, }); -console.log("results:"); + +type Pair = { user: Address; deliveryAt: bigint }; +const pairs: Pair[] = []; for (let i = 0; i < USERS.length; i++) { - const ids = idLists[i] as readonly Hex[]; - console.log(` ${USERS[i]} → ${ids.length} positions`); - for (const id of ids) console.log(` ${id}`); + const user = USERS[i]!; + const dates = dateLists[i] as readonly bigint[]; + console.log(` ${user} → ${dates.length} expiries`); + for (const deliveryAt of dates) { + console.log(` ${deliveryAt}`); + pairs.push({ user, deliveryAt }); + } } -const allIds: Hex[] = []; -for (const ids of idLists as readonly (readonly Hex[])[]) allIds.push(...ids); -if (allIds.length === 0) { +if (pairs.length === 0) { console.log("\nNo positions found — bootstrap would return early."); process.exit(0); } -console.log(`\n--- Stage 2: getPositionById for ${allIds.length} ids ---`); +console.log(`\n--- Stage 2: getUserPosition for ${pairs.length} aggregates ---`); const positions = await client.multicall({ - contracts: allIds.map((id) => ({ + contracts: pairs.map((p) => ({ address: FUTURES, abi: FuturesAbi, - functionName: "getPositionById" as const, - args: [id] as const, + functionName: "getUserPosition" as const, + args: [p.user, p.deliveryAt] as const, })), allowFailure: false, }); @@ -73,46 +71,18 @@ const now = BigInt(Math.floor(Date.now() / 1000)); const block = await client.getBlock(); console.log("wall-clock now:", now, " block.timestamp:", block.timestamp); -const expirationIntervalDays = (await client.readContract({ - address: FUTURES, - abi: FuturesAbi, - functionName: "expirationIntervalDays", -})) as number; -const window = BigInt(expirationIntervalDays) * 86_400n; -console.log( - "expirationIntervalDays:", - expirationIntervalDays, - "→ window:", - window, - "s", -); - let live = 0; let pastDue = 0; -let expired = 0; -for (let i = 0; i < allIds.length; i++) { - const id = allIds[i] as Hex; - const pos = positions[i] as { - seller: Address; - buyer: Address; - deliveryAt: bigint; - }; - const closed = pos.seller === "0x0000000000000000000000000000000000000000"; - if (closed) { - console.log(` ${id} CLOSED (seller==0)`); - continue; - } +for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i]!; + const pos = positions[i] as { netQuantity: bigint; netEntryValue: bigint }; + if (pos.netQuantity === 0n) continue; live++; - const due = block.timestamp >= pos.deliveryAt; - const dead = block.timestamp > pos.deliveryAt + window; - if (dead) expired++; - else if (due) pastDue++; + const due = block.timestamp >= pair.deliveryAt; + if (due) pastDue++; console.log( - ` ${id} seller=${pos.seller} buyer=${pos.buyer} deliveryAt=${pos.deliveryAt} ${ - dead ? "EXPIRED-WINDOW" : due ? "PAST-DUE" : "FUTURE" - }`, + ` ${pair.user} @ ${pair.deliveryAt}: qty=${pos.netQuantity} entryValue=${pos.netEntryValue}` + + (due ? " PAST_DUE" : ""), ); } -console.log( - `\nsummary: ${live} live, ${pastDue} past-due (settleable), ${expired} expired-window`, -); +console.log(`\nlive aggregates: ${live}, past-due: ${pastDue}`); diff --git a/keeper/src/config.ts b/keeper/src/config.ts index edd1dc3..dca8e94 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -68,14 +68,11 @@ export interface Config { /** Optional fast pre-filter (token decimals). */ minNotional?: bigint; /** - * Max futures lots closed per `liquidatePositions` tx (gas-bounded - * chunking, "Option A"). `reduceToTarget` sends ONE worst-first chunk of - * at most this many lots; the planner loop re-invokes it (re-snapshotting - * each time) until the account is healthy. Each `_liquidateOnePosition` is - * roughly 150-250k gas, so 50 keeps a full chunk (~12M) well under Base's - * 30M block limit. Lower it for chains with tighter blocks or unusually - * expensive settlement paths. Must stay ≤ `MAX_POSITION_ITERATIONS × this` - * worth of headroom for the largest realistic single-user book. + * Max futures expiry legs closed per `liquidatePositions` tx (gas-bounded + * chunking). `reduceToTarget` sends ONE worst-first chunk of at most this + * many `(deliveryAt, closeQty)` pairs; the planner loop re-invokes it + * (re-snapshotting each time) until the account is healthy. Keep ≤ ~50 so + * a full chunk stays well under Base's block gas limit. */ maxLotsPerLiquidationTx: number; }; @@ -205,7 +202,7 @@ export interface Config { /** * Cadence of the periodic safety-net sweep over tracked positions. Picks * up anything the per-position timers missed (process restarts, missed - * `LotCreated` events, clock skew). Live timers are the hot path. + * `OrderMatched` events, clock skew). Live timers are the hot path. */ sweepIntervalMs: number; /** diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index 32af590..2030f84 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -2,7 +2,7 @@ import { BaseError, ContractFunctionRevertedError, encodeFunctionData, - zeroAddress, + getAddress, type Address, type Hex, type Log, @@ -16,79 +16,33 @@ import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import { formatGasCost } from "../tx/gasCost.ts"; /** - * Optional keeper module that calls `Futures.settlePosition(positionId)` on - * every active futures position the moment its `deliveryAt` (maturity) is - * reached. Settlement marks the entire position to the expiration's pinned - * settlement price (recorded once per `deliveryAt` from the oracle; the first - * settle lazily pins it) and cash-settles PnL through the insurance fund — - * there is no physical hashrate delivery, escrow, breach penalty, or validator - * involvement. Pinning the price per expiration makes settlement deterministic: - * every position at a `deliveryAt` settles at the same price no matter when its - * tx lands. + * Optional keeper module that calls `Futures.settlePosition(user, deliveryAt)` + * on every active futures aggregate the moment its `deliveryAt` (maturity) is + * reached. Settlement pins the expiry price (lazily on first settle) and + * cash-settles that user's unilateral PnL through the insurance fund. * - * Authorization: `settlePosition` is permissionless — any address may settle - * any matured position. The keeper needs no special role; its signer only has - * to hold enough gas to broadcast. (Contrast the retired validator-gated - * `closeDelivery`, which required `_msgSender() == validatorAddress` or a - * position participant.) Because settlement has no upper time bound, a matured - * position can always be settled later — there are no permanently stuck lots. + * Authorization: `settlePosition` is permissionless. * * Hot path is event-driven: * - * LotCreated ─▶ schedule one-shot timer at deliveryAt + settleDelay - * LotClosed ─▶ cancel the timer + drop from index - * timer fires ─▶ settle(lotId) + * OrderMatched ─▶ re-index maker + taker active expiries + * PositionSettled ─▶ drop that (user, deliveryAt) from the index + * timer fires ─▶ settle matured tracked aggregates * - * Cold-start safety net (two redundant paths — either alone is sufficient): + * Cold-start safety net: * - * bootstrapFromUsers(addrs) ─▶ for each address, read `getPositionIds(user)` - * and `getPositionById(id)` via multicall, then - * index whatever positions are still alive - * on-chain. View-only — works on any RPC, - * including providers that rate-limit - * `eth_getLogs` (Alchemy free tier caps at - * 10 blocks, which makes log backfill - * impractical for any non-trivial range). - * This is the recommended primary path and - * is wired automatically in `index.ts` from - * `tracker.onAdded` and once at boot from - * `tracker.list()`. - * backfill(fromBlock) ─▶ replay LotCreated/LotClosed in - * chunks. Discovers positions even for - * participants the tracker doesn't know - * about, but breaks on rate-limited - * providers — keep `BACKFILL_FROM_BLOCK` - * small or unset on Alchemy free. - * sweep() ─▶ every `sweepIntervalMs`, scan tracked - * positions for any in - * `[deliveryAt, deliveryAt + duration]` that - * haven't been settled — covers dropped - * events, timer drift, post-restart recovery, - * and oracle-staleness retries. - * - * Single source of truth for "is this position alive": the contract emits - * `LotClosed` at the end of every `_removePosition`, including the - * cash-settlement path inside `settlePosition` itself. The module never has - * to track its own settled-set across restarts — once settled, the contract - * removes the position and `getPositionById(id).seller == 0` permanently. + * bootstrapFromUsers(addrs) ─▶ getActiveDeliveryDates + getUserPosition + * backfill(fromBlock) ─▶ replay OrderMatched / PositionSettled + * sweep() ─▶ periodic settle of past-due tracked rows */ export class DeliveryCoordinator { - /** Active positions known to the module: lotId → metadata. */ - private readonly tracked = new Map(); - /** One-shot timers keyed by positionId. Cleared on settle / close / stop. */ - private readonly timers = new Map(); - /** Set of positions with an in-flight `settle()` — coalesces duplicate triggers. */ - private readonly inflight = new Set(); - /** - * Serialized broadcast chain: every `attemptSettle` awaits the previous - * one before sending its own tx. The keeper has a single signer, so two - * concurrent `writeContract` calls would race on the same nonce and one - * would revert. Sweeps fire many candidates in parallel (e.g. multiple - * positions sharing one `deliveryAt`); without this, the second-onward - * txs would be rejected by the node. - */ + /** Active aggregates: trackKey → metadata. */ + private readonly tracked = new Map(); + /** One-shot timers keyed by trackKey. */ + private readonly timers = new Map(); + /** In-flight settles — coalesces duplicate triggers. */ + private readonly inflight = new Set(); private txChain: Promise = Promise.resolve(); - /** Disposers returned by `watchContractEvent`. */ private unwatchers: Array<() => void> = []; private sweepTimer: NodeJS.Timeout | undefined; private running = false; @@ -107,20 +61,9 @@ export class DeliveryCoordinator { this.chain = chain; this.config = config; this.logger = logger.child({ component: "deliveryCoordinator" }); - // Optional — see FuturesVenue for the rationale. Used only to enrich - // the two confirmed-tx logs (batched `multicall` and single - // `settlePosition`) with a `gasCostUsd` field. this.ethUsdFeed = ethUsdFeed; } - /** - * Subscribes to `LotCreated` / `LotClosed`, primes the duration - * cache, and starts the periodic safety-net sweep. Idempotent. - * - * Backfill is the caller's responsibility (via `backfill(fromBlock)`) so - * the runtime can sequence it after live subscriptions are wired — same - * pattern as `ParticipantTracker`. - */ async start(): Promise { if (this.running) return; this.running = true; @@ -134,14 +77,14 @@ export class DeliveryCoordinator { this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "LotCreated", - onLogs: (logs) => this.onLotCreated(logs), + eventName: "OrderMatched", + onLogs: (logs) => this.onOrderMatched(logs), }), this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "LotClosed", - onLogs: (logs) => this.onLotClosed(logs), + eventName: "PositionSettled", + onLogs: (logs) => this.onPositionSettled(logs), }), ); @@ -150,7 +93,6 @@ export class DeliveryCoordinator { }, this.config.delivery.sweepIntervalMs); } - /** Tears down all subscriptions, timers, and the sweep loop. Idempotent. */ stop(): void { if (!this.running) return; this.running = false; @@ -176,14 +118,8 @@ export class DeliveryCoordinator { } /** - * Replay `LotCreated` and `LotClosed` in `[fromBlock, head]` so - * the in-memory index reflects every position the contract still considers - * active. Closed positions cancel their `created` entry as the same scan - * runs in chronological order — no second pass needed. - * - * After backfill, kicks one immediate sweep so any positions whose - * `deliveryAt` has already passed get settled without waiting for the - * sweep timer's first tick. + * Replay `OrderMatched` / `PositionSettled` in `[fromBlock, head]`. + * Matched events re-index users; settled events drop track keys. */ async backfill(fromBlock: bigint, chunkSize: bigint): Promise { if (chunkSize <= 0n) { @@ -209,31 +145,28 @@ export class DeliveryCoordinator { "delivery backfill: starting", ); - // Single scan over both events per chunk so creates and closes interleave - // in block order — a position created and then closed in the same chunk - // never lingers in `tracked` after the chunk drains. let chunkErrors = 0; for (let start = fromBlock; start <= head; start += chunkSize) { const end = start + chunkSize - 1n > head ? head : start + chunkSize - 1n; try { - const [created, closed] = await Promise.all([ + const [matched, settled] = await Promise.all([ this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "LotCreated", + eventName: "OrderMatched", fromBlock: start, toBlock: end, }), this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: FuturesAbi, - eventName: "LotClosed", + eventName: "PositionSettled", fromBlock: start, toBlock: end, }), ]); - this.onLotCreated(created as unknown as readonly Log[]); - this.onLotClosed(closed as unknown as readonly Log[]); + this.onOrderMatched(matched as unknown as readonly Log[]); + this.onPositionSettled(settled as unknown as readonly Log[]); } catch (err) { chunkErrors++; this.logger.error( @@ -252,22 +185,8 @@ export class DeliveryCoordinator { } /** - * View-based discovery: read every still-alive futures position belonging - * to `users` and index them. Trailing `sweep()` settles anything past - * `deliveryAt`. Robust against `eth_getLogs` rate-limit caps because it - * never scans logs. - * - * Wired in `index.ts` from - * - `tracker.onAdded` (per-user, on every newly-discovered participant) - * - the boot sequence's `tracker.list()` (one batched pass after - * `tracker.backfill` finishes) - * so any participant the tracker eventually discovers — by webhook, live - * event, or backfill — also has their futures positions indexed. - * - * Two-stage multicall to keep the contract surface narrow: stage 1 reads - * `getPositionIds(user)` for every user, stage 2 hydrates each id via - * `getPositionById`. Closed positions (returned with `seller == address(0)` - * by the `delete positions[id]` in `_removePosition`) are filtered out. + * View-based discovery: read every still-alive futures aggregate belonging + * to `users` and index them. */ async bootstrapFromUsers(users: readonly Address[]): Promise { if (users.length === 0) { @@ -278,52 +197,16 @@ export class DeliveryCoordinator { return; } - const positionIdLists = (await this.chain.publicClient.multicall({ - contracts: users.map((u) => ({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getPositionIds" as const, - args: [u] as const, - })), - allowFailure: false, - })) as readonly (readonly Hex[])[]; - - // `getPositionIds(user)` returns positions where the user is EITHER - // buyer OR seller, so a single position with both participants - // tracked (typical) shows up twice across the per-user calls. Dedup - // so the operator-facing counter reflects distinct positions, not - // raw entries — operators kept asking "why does positionsOnChain not - // match tracked.size?" because they never see the same id twice on - // a block explorer. - const uniqueOnChain = new Set(); - const allIds: Hex[] = []; - for (const ids of positionIdLists) { - for (const id of ids) { - uniqueOnChain.add(id); - if (this.tracked.has(id)) continue; - allIds.push(id); - } - } - let indexed = 0; - if (allIds.length > 0) { - indexed = await this.indexPositions(allIds); + for (const user of users) { + indexed += await this.indexUserPositionsInternal(user); } - // Operator-readable summary regardless of whether anything new was - // indexed. The "total" / "pastDue" / "nextDueAt" tuple is the answer - // to "is the delivery keeper actually doing anything?": - // - total=0 → wallet has nothing to settle (healthy idle) - // - pastDue>0 → next sweep tick attempts a multicall - // - pastDue=0 + ETA → keeper is correctly waiting for the timer - // at `nextDueAt` (no bug — settlement isn't - // valid before deliveryAt on-chain) const pastDue = this.countPastDuePositions(); const nextDueAt = this.findEarliestDeliveryAt(); this.logger.info( { users: users.length, - uniquePositionsOnChain: uniqueOnChain.size, indexed, total: this.tracked.size, pastDue, @@ -338,11 +221,7 @@ export class DeliveryCoordinator { await this.sweep(); } - /** Count tracked positions whose deliveryAt is at or before chain head. */ private countPastDuePositions(): number { - // Approximate using wall-clock — within a block of chain time on - // any production network, accurate enough for an operator-facing - // summary. The actual sweep uses `block.timestamp` for correctness. const nowSec = BigInt(Math.floor(Date.now() / 1000)); let n = 0; for (const pos of this.tracked.values()) { @@ -351,13 +230,6 @@ export class DeliveryCoordinator { return n; } - /** - * Earliest `deliveryAt` across all tracked positions. Returned to the - * boot summary as "the next time the keeper expects to do work" so an - * operator can sanity-check "all 5 positions are due in 3 days, that's - * why nothing's happening" without having to hop to a block explorer. - * `undefined` when there are no tracked positions. - */ private findEarliestDeliveryAt(): bigint | undefined { let earliest: bigint | undefined; for (const pos of this.tracked.values()) { @@ -367,33 +239,10 @@ export class DeliveryCoordinator { return earliest; } - /** - * Single-user variant of `bootstrapFromUsers` — exposed separately so - * `tracker.onAdded` can wire it without paying the multicall overhead - * for one user. Errors are caught and logged: the listener path must - * never throw into the tracker. - */ + /** Single-user index — wired from `tracker.onAdded`. Never throws. */ async indexUserPositions(user: Address): Promise { - let ids: readonly Hex[]; try { - ids = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getPositionIds", - args: [user], - })) as readonly Hex[]; - } catch (err) { - this.logger.error({ err, user }, "delivery: getPositionIds failed"); - return; - } - const fresh = ids.filter((id) => !this.tracked.has(id)); - if (fresh.length === 0) return; - try { - const indexed = await this.indexPositions(fresh); - // INFO (not debug): operator-visible signal that the keeper - // discovered a user's futures and is now responsible for settling - // them. If you ever wonder "did the keeper see my new account?" - // this is the line you grep for. + const indexed = await this.indexUserPositionsInternal(user); if (indexed > 0) { this.logger.info( { user, indexed, total: this.tracked.size }, @@ -401,107 +250,94 @@ export class DeliveryCoordinator { ); } } catch (err) { - this.logger.error({ err, user }, "delivery: indexPositions failed"); + this.logger.error({ err, user }, "delivery: indexUserPositions failed"); } } - /** - * Internal: hydrate `ids` via multicalled `getPositionById` and upsert - * the live ones (`seller != 0`) into the tracked map plus a per-position - * timer. Returns the number of newly-indexed positions. - */ - private async indexPositions(ids: readonly Hex[]): Promise { + private async indexUserPositionsInternal(user: Address): Promise { + let deliveryAts: readonly bigint[]; + try { + deliveryAts = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: FuturesAbi, + functionName: "getActiveDeliveryDates", + args: [user], + })) as readonly bigint[]; + } catch (err) { + this.logger.error({ err, user }, "delivery: getActiveDeliveryDates failed"); + return 0; + } + if (deliveryAts.length === 0) return 0; + const positions = (await this.chain.publicClient.multicall({ - contracts: ids.map((id) => ({ + contracts: deliveryAts.map((deliveryAt) => ({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getPositionById" as const, - args: [id] as const, + functionName: "getUserPosition" as const, + args: [user, deliveryAt] as const, })), allowFailure: false, - })) as readonly { - seller: Address; - buyer: Address; - deliveryAt: bigint; - }[]; + })) as readonly { netQuantity: bigint; netEntryValue: bigint }[]; let added = 0; - for (let i = 0; i < ids.length; i++) { - const id = ids[i] as Hex; - const pos = positions[i] as { - seller: Address; - buyer: Address; - deliveryAt: bigint; - }; - // `_removePosition` deletes the slot — `seller == 0` means already - // closed/settled. Skip without touching state. - if (pos.seller === zeroAddress) continue; - if (this.tracked.has(id)) continue; - const tracked: TrackedPosition = { - positionId: id, - deliveryAt: pos.deliveryAt, - seller: pos.seller, - buyer: pos.buyer, - }; - this.tracked.set(id, tracked); - this.scheduleTimer(tracked); - added++; + for (let i = 0; i < deliveryAts.length; i++) { + const deliveryAt = deliveryAts[i]!; + const pos = positions[i]; + if (pos === undefined || pos.netQuantity === 0n) continue; + if (this.upsertTracked(user, deliveryAt)) added++; } return added; } - /** - * Scan all tracked positions; settle any whose `deliveryAt` (maturity) is - * past. Skips positions with an in-flight settle to avoid duplicate sends. - * Public for tests. - * - * Uses the chain's latest `block.timestamp` rather than `Date.now()` so the - * sweep agrees with the contract's maturity check (`block.timestamp >= - * deliveryAt`). `settlePosition` has no upper time bound, so there is no - * "window expired" case — a matured position stays settleable indefinitely. - * On hardhat with `evm_setNextBlockTimestamp`, chain time and wall-clock can - * diverge by years; in production they're within one block of each other so - * this read is essentially free. - */ + /** Insert or refresh a tracked aggregate. Returns true if newly added. */ + private upsertTracked(user: Address, deliveryAt: bigint): boolean { + const key = trackKey(user, deliveryAt); + if (this.tracked.has(key)) return false; + const tracked: TrackedPosition = { + user: getAddress(user), + deliveryAt, + }; + this.tracked.set(key, tracked); + this.scheduleTimer(tracked); + return true; + } + + private dropTracked(user: Address, deliveryAt: bigint): void { + const key = trackKey(user, deliveryAt); + this.tracked.delete(key); + const t = this.timers.get(key); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(key); + } + } + async sweep(): Promise { const latestBlock = await this.chain.publicClient.getBlock(); const nowSec = latestBlock.timestamp; const candidates: TrackedPosition[] = []; for (const pos of this.tracked.values()) { - if (this.inflight.has(pos.positionId)) continue; + if (this.inflight.has(trackKey(pos.user, pos.deliveryAt))) continue; if (nowSec < pos.deliveryAt) continue; candidates.push(pos); } if (candidates.length === 0) { - // Visibility for "the sweep ran but found nothing" — at debug so - // a healthy idle keeper isn't noisy in tails. Tracked-but-not-yet- - // due counts in the message let an operator confirm the index is - // populated even when no work is pending. this.logger.debug( { tracked: this.tracked.size, pendingFuture: this.tracked.size }, "delivery sweep: nothing past-due", ); return; } - // INFO so an active sweep is visible in default-config tails. Sweeps - // are bursty (most ticks find nothing, occasional ticks settle a - // batch) so this won't flood logs. this.logger.info( { candidates: candidates.length, tracked: this.tracked.size }, "delivery sweep: settling", ); - // Batch via `Futures.multicall(bytes[])` (OZ MulticallUpgradeable) so - // every settlement in this sweep tick rides one transaction → one - // nonce → no `replacement transaction underpriced` race against - // concurrent manual sends or stale pending txs from a previous run. - // We cap batch size to keep gas usage bounded; large sweeps spread - // across multiple batches, each its own serial txChain entry. - const ids = candidates.map((c) => c.positionId); + const max = Math.max(1, this.config.delivery.maxBatchSize); - for (let i = 0; i < ids.length; i += max) { - const slice = ids.slice(i, i + max); + for (let i = 0; i < candidates.length; i += max) { + const slice = candidates.slice(i, i + max); try { await this.settleBatch(slice); } catch (err) { @@ -513,54 +349,26 @@ export class DeliveryCoordinator { } } - /** Public for tests. Number of positions currently scheduled for settlement. */ size(): number { return this.tracked.size; } - /** Public for tests. Whether `positionId` is currently scheduled. */ - has(positionId: Hex): boolean { - return this.tracked.has(positionId); + /** Public for tests. */ + has(user: Address, deliveryAt: bigint): boolean { + return this.tracked.has(trackKey(user, deliveryAt)); } - /** - * Public for tests. Settles a single position via the batch path - * (`settleBatch([id])`). Kept for tests and as a stable single-id entry - * point — the actual broadcast still goes through `Futures.multicall` - * with one entry, so the nonce / serialization model is identical to - * multi-id sweeps. - */ - async settle(positionId: Hex): Promise { - await this.settleBatch([positionId]); + async settle(user: Address, deliveryAt: bigint): Promise { + await this.settleBatch([{ user: getAddress(user), deliveryAt }]); } - /** - * Bundles up to `maxBatchSize` `settlePosition` calls into a single - * `Futures.multicall(bytes[])` transaction. OZ `MulticallUpgradeable` - * uses `delegatecall` per entry, so `msg.sender` is preserved — though - * `settlePosition` is permissionless, so no auth depends on the sender. - * - * Two-phase to keep one bad apple from spoiling the batch: - * 1. Per-id `simulateContract` in parallel — drops candidates that - * would revert (already-settled, not yet matured, oracle stale, etc). - * Each revert is reported through the same severity taxonomy as - * individual settles. - * 2. One `multicall` write tx for the survivors. If the *write* - * reverts (rare — simulate-then-write race), we fall back to - * per-id `attemptSettle` so a single newly-poisoned id can't - * block the whole sweep tick. - * - * Serialized through `txChain` so two batches (e.g. two slices of a - * sweep larger than `maxBatchSize`) ride sequential nonces. Per-id - * `inflight` set still applies so a slow batch can't be re-queued - * concurrently from a timer fire mid-sweep. - */ - async settleBatch(positionIds: readonly Hex[]): Promise { - const fresh: Hex[] = []; - for (const id of positionIds) { - if (this.inflight.has(id)) continue; - fresh.push(id); - this.inflight.add(id); + async settleBatch(positions: readonly TrackedPosition[]): Promise { + const fresh: TrackedPosition[] = []; + for (const pos of positions) { + const key = trackKey(pos.user, pos.deliveryAt); + if (this.inflight.has(key)) continue; + fresh.push({ user: getAddress(pos.user), deliveryAt: pos.deliveryAt }); + this.inflight.add(key); } if (fresh.length === 0) return; const next = this.txChain.then(() => this.attemptBatch(fresh)); @@ -568,62 +376,53 @@ export class DeliveryCoordinator { try { await next; } finally { - for (const id of fresh) this.inflight.delete(id); + for (const pos of fresh) { + this.inflight.delete(trackKey(pos.user, pos.deliveryAt)); + } } } - /** - * Phase 1: simulate every candidate, classify outcomes, build the - * settleable subset. Phase 2: one batched write or fall through to - * per-id retries if the batch tx itself fails. - */ - private async attemptBatch(positionIds: readonly Hex[]): Promise { + private async attemptBatch(positions: readonly TrackedPosition[]): Promise { type SimParams = Parameters< typeof this.chain.publicClient.simulateContract >[0]; const simResults = await Promise.allSettled( - positionIds.map((id) => + positions.map((pos) => this.chain.publicClient.simulateContract({ address: this.config.futures.address, abi: FuturesAbi, functionName: "settlePosition", - args: [id], + args: [pos.user, pos.deliveryAt], account: this.chain.account, } as unknown as SimParams), ), ); - const settleable: Hex[] = []; - for (let i = 0; i < positionIds.length; i++) { - const id = positionIds[i] as Hex; + const settleable: TrackedPosition[] = []; + for (let i = 0; i < positions.length; i++) { + const pos = positions[i]!; const r = simResults[i] as PromiseSettledResult; if (r.status === "fulfilled") { - settleable.push(id); + settleable.push(pos); continue; } const decoded = decodeRecoverableRevert(r.reason); if (decoded !== undefined) { - this.logRecoverableRevert(decoded, id); + this.logRecoverableRevert(decoded, pos); if (decoded === "PositionNotExists") { - this.tracked.delete(id); - const t = this.timers.get(id); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(id); - } + this.dropTracked(pos.user, pos.deliveryAt); } continue; } - // Unknown revert — log error but don't kill the rest of the batch. this.logger.error( - { err: r.reason, positionId: id }, + { err: r.reason, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, "delivery: simulate failed with non-recoverable error — skipping from batch", ); } if (settleable.length === 0) { this.logger.debug( - { batchSize: positionIds.length }, + { batchSize: positions.length }, "delivery batch: nothing to broadcast after simulate filter", ); return; @@ -634,30 +433,25 @@ export class DeliveryCoordinator { { batchSize: settleable.length }, "[dryRun] would call Futures.multicall(settlePosition × N)", ); - for (const id of settleable) this.tracked.delete(id); + for (const pos of settleable) this.dropTracked(pos.user, pos.deliveryAt); return; } - // Encode each settlePosition into bytes for OZ multicall(bytes[]). - // Encoding can only fail on a malformed positionId (e.g. wrong - // bytes32 width from a corrupted RPC read). We isolate that - // per-position rather than letting one bad id swallow the whole - // batch — same "one bad apple" guarantee we extend through simulate. const calldatas: Hex[] = []; - const encodableIds: Hex[] = []; - for (const id of settleable) { + const encodable: TrackedPosition[] = []; + for (const pos of settleable) { try { const data = encodeFunctionData({ abi: FuturesAbi, functionName: "settlePosition", - args: [id], + args: [pos.user, pos.deliveryAt], }); calldatas.push(data); - encodableIds.push(id); + encodable.push(pos); } catch (err) { this.logger.error( - { err, positionId: id }, - "delivery: encodeFunctionData threw — dropping malformed id from batch", + { err, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + "delivery: encodeFunctionData threw — dropping malformed entry from batch", ); } } @@ -668,13 +462,6 @@ export class DeliveryCoordinator { >[0]; let hash: Hex; try { - // `withUnstickRetry` is the auto-recovery for the most common - // tx-submission failure on this signer: a stuck pending tx from - // a previous keeper run (or a previous attempt that timed out - // mid-broadcast). On `replacement transaction underpriced` it - // walks the wallet's pending nonces, evicts each with a 0-value - // self-transfer at 3× current gas, then retries our multicall - // exactly once. Anything still wrong on retry surfaces normally. hash = await withUnstickRetry(this.chain, this.logger, () => this.chain.walletClient.writeContract({ address: this.config.futures.address, @@ -686,11 +473,6 @@ export class DeliveryCoordinator { } as unknown as WriteParams), ); } catch (err) { - // Tx-submission failures (nonce races, replacement underpriced, - // mempool-full, transient RPC errors) are recoverable: the next - // sweep will retry. We do NOT want to crash the keeper here — - // unhandled rejection on a setTimeout-fired batch took the whole - // process down in production. if (isTransientTxError(err)) { this.logger.warn( { err, batchSize: settleable.length }, @@ -698,19 +480,16 @@ export class DeliveryCoordinator { ); return; } - // Non-transient revert — could be one position turned bad between - // simulate and write (state moved). Fall back to per-id attempts - // so the others still settle on this sweep. this.logger.warn( - { err, batchSize: encodableIds.length }, + { err, batchSize: encodable.length }, "delivery batch: write reverted — falling back to per-position retries", ); - for (const id of encodableIds) { + for (const pos of encodable) { try { - await this.attemptSettle(id); + await this.attemptSettle(pos); } catch (innerErr) { this.logger.error( - { err: innerErr, positionId: id }, + { err: innerErr, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, "delivery: per-position fallback failed — leaving for next sweep", ); } @@ -726,24 +505,19 @@ export class DeliveryCoordinator { { hash, blockNumber: receipt.blockNumber.toString(), - batchSize: encodableIds.length, + batchSize: encodable.length, ...formatGasCost(receipt, this.ethUsdFeed), }, "delivery batch: multicall confirmed", ); - for (const id of encodableIds) { - this.tracked.delete(id); - const t = this.timers.get(id); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(id); - } + for (const pos of encodable) { + this.dropTracked(pos.user, pos.deliveryAt); } } - private async attemptSettle(positionId: Hex): Promise { - const args = [positionId] as const; + private async attemptSettle(pos: TrackedPosition): Promise { + const args = [pos.user, pos.deliveryAt] as const; type SimParams = Parameters< typeof this.chain.publicClient.simulateContract @@ -764,16 +538,9 @@ export class DeliveryCoordinator { } catch (err) { const decoded = decodeRecoverableRevert(err); if (decoded !== undefined) { - this.logRecoverableRevert(decoded, positionId); - // PositionNotExists → contract no longer accepts settlement (already - // settled). Drop from the index so we don't keep retrying. + this.logRecoverableRevert(decoded, pos); if (decoded === "PositionNotExists") { - this.tracked.delete(positionId); - const t = this.timers.get(positionId); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(positionId); - } + this.dropTracked(pos.user, pos.deliveryAt); } return; } @@ -781,8 +548,11 @@ export class DeliveryCoordinator { } if (this.config.keeper.dryRun) { - this.logger.info({ positionId }, "[dryRun] would call settlePosition"); - this.tracked.delete(positionId); + this.logger.info( + { user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + "[dryRun] would call settlePosition", + ); + this.dropTracked(pos.user, pos.deliveryAt); return; } @@ -798,181 +568,115 @@ export class DeliveryCoordinator { }); this.logger.info( { - positionId, + user: pos.user, + deliveryAt: pos.deliveryAt.toString(), hash, blockNumber: receipt.blockNumber.toString(), ...formatGasCost(receipt, this.ethUsdFeed), }, "delivery: settlePosition confirmed", ); - this.tracked.delete(positionId); - const t = this.timers.get(positionId); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(positionId); - } + this.dropTracked(pos.user, pos.deliveryAt); } - // ── log handlers ───────────────────────────────────────────────────────── - // Mirror the live-and-backfill duality used by ParticipantTracker — the - // same handler is fed both `watchContractEvent` callbacks and historical - // `getContractEvents` results, so a future ABI rename surfaces here once. - - private onLotCreated(logs: readonly Log[]): void { + private onOrderMatched(logs: readonly Log[]): void { type Args = { - lotId?: Hex; - seller?: Address; - buyer?: Address; + maker?: Address; + taker?: Address; deliveryAt?: bigint; + makerNetQtyAfter?: bigint; + takerNetQtyAfter?: bigint; }; - let added = 0; + const users = new Set
(); for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; - if ( - args === undefined || - args.lotId === undefined || - args.deliveryAt === undefined || - args.seller === undefined || - args.buyer === undefined - ) { - continue; + if (args === undefined) continue; + // Fast path: if post-match qty is available, upsert/drop without RPC. + if (args.deliveryAt !== undefined) { + if (args.maker !== undefined && args.makerNetQtyAfter !== undefined) { + if (args.makerNetQtyAfter === 0n) this.dropTracked(args.maker, args.deliveryAt); + else this.upsertTracked(args.maker, args.deliveryAt); + } else if (args.maker !== undefined) { + users.add(args.maker); + } + if (args.taker !== undefined && args.takerNetQtyAfter !== undefined) { + if (args.takerNetQtyAfter === 0n) this.dropTracked(args.taker, args.deliveryAt); + else this.upsertTracked(args.taker, args.deliveryAt); + } else if (args.taker !== undefined) { + users.add(args.taker); + } + } else { + if (args.maker !== undefined) users.add(args.maker); + if (args.taker !== undefined) users.add(args.taker); } - const lotId = args.lotId; - // Backfill can replay an event we already indexed (live watcher - // overlap). De-dupe on lotId so we don't double-schedule. - if (this.tracked.has(lotId)) continue; - const tracked: TrackedPosition = { - positionId: lotId, - deliveryAt: args.deliveryAt, - seller: args.seller, - buyer: args.buyer, - }; - this.tracked.set(lotId, tracked); - this.scheduleTimer(tracked); - added++; - // INFO per *new* position so the operator sees live activity in - // real time. We log inside the loop (not after) so each id and - // its `deliveryAt` is searchable in tails — useful when chasing - // a specific position's lifecycle. Backfill replays go through - // the dedupe `continue` above and stay silent. - this.logger.info( - { - lotId, - seller: args.seller, - buyer: args.buyer, - deliveryAt: args.deliveryAt.toString(), - total: this.tracked.size, - }, - "delivery: new position indexed from live event", - ); } - if (added === 0) return; + for (const user of users) { + void this.indexUserPositions(user); + } } - private onLotClosed(logs: readonly Log[]): void { - type Args = { lotId?: Hex }; + private onPositionSettled(logs: readonly Log[]): void { + type Args = { user?: Address; deliveryAt?: bigint }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; - if (args?.lotId === undefined) continue; - const lotId = args.lotId; - this.tracked.delete(lotId); - const t = this.timers.get(lotId); - if (t !== undefined) { - clearTimeout(t); - this.timers.delete(lotId); - } + if (args?.user === undefined || args.deliveryAt === undefined) continue; + this.dropTracked(args.user, args.deliveryAt); } } - /** - * Differentiated logging for the recoverable-revert taxonomy. Two buckets: - * - * info — terminal but benign: the contract no longer accepts settlement - * because someone else already settled it (`PositionNotExists`). - * We drop and move on. - * debug — transient, will retry on the next sweep with no operator action - * needed (not yet matured, oracle stale, oracle invalid). - * - * Since `settlePosition` is permissionless and has no upper time bound, the - * old operator-paging cases (wrong validator key, expired settlement window) - * no longer exist — every revert here is either benign or self-healing. - */ - private logRecoverableRevert(revert: RecoverableRevert, positionId: Hex): void { + private logRecoverableRevert(revert: RecoverableRevert, pos: TrackedPosition): void { if (revert === "PositionNotExists") { this.logger.info( - { positionId, revert }, + { user: pos.user, deliveryAt: pos.deliveryAt.toString(), revert }, "delivery: position already settled by someone else — dropping from index", ); return; } - // PositionDeliveryNotStartedYet, OracleStale, InvalidOracle — sweep retries. this.logger.debug( - { positionId, revert }, + { user: pos.user, deliveryAt: pos.deliveryAt.toString(), revert }, "delivery: settlePosition skipped (transient revert, will retry)", ); } - /** - * Fire-and-forget timer at `deliveryAt + settleDelay`. If the time has - * already passed we still schedule a 0ms timer rather than calling - * `settle()` synchronously — keeps the log-handler hot path non-blocking - * and lets the periodic sweep idempotently retry on failure. - * - * `setTimeout` is bounded at ~24.8 days (int32 ms). Positions further out - * than that fall through to the periodic sweep — a daily-ish settlement - * cadence is far below that ceiling, so this only matters for synthetic - * test fixtures and far-future markets. - */ private scheduleTimer(pos: TrackedPosition): void { - const existing = this.timers.get(pos.positionId); + const key = trackKey(pos.user, pos.deliveryAt); + const existing = this.timers.get(key); if (existing !== undefined) clearTimeout(existing); const targetMs = Number(pos.deliveryAt) * 1000 + this.config.delivery.settleDelayMs; const delayMs = Math.max(0, targetMs - Date.now()); if (delayMs > MAX_TIMEOUT_MS) { - // Out of `setTimeout`'s safe range — let the sweep handle it. return; } - // Kick a sweep rather than calling `settle` directly. When many - // positions share the same `deliveryAt` (typical for a single-trader - // book), all their timers fire on the same tick — routing through - // sweep coalesces them into one batched `Futures.multicall` tx - // instead of N serial single-id txs racing for the next nonce. const timer = setTimeout(() => { void this.sweep().catch((err) => { this.logger.error( - { err, positionId: pos.positionId }, + { err, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, "delivery: timer-fired sweep threw", ); }); }, delayMs); - // Don't keep the event loop alive solely for delivery timers — the - // process should exit cleanly when other components shut down. if (typeof timer.unref === "function") timer.unref(); - this.timers.set(pos.positionId, timer); + this.timers.set(key, timer); } } +export function trackKey(user: Address, deliveryAt: bigint): string { + return `${getAddress(user).toLowerCase()}:${deliveryAt.toString()}`; +} + interface TrackedPosition { - positionId: Hex; + user: Address; deliveryAt: bigint; - seller: Address; - buyer: Address; } -/** `setTimeout`'s int32 ms ceiling — values above are clamped silently to 1ms. */ const MAX_TIMEOUT_MS = 2_147_483_647; -/** Reverts the module treats as "skip this attempt" rather than fatal. */ type RecoverableRevert = | "PositionNotExists" | "PositionDeliveryNotStartedYet" - // Hashprice oracle hasn't ticked within `MAX_ORACLE_STALENESS` (1h). The - // periodic sweep keeps the position queued; the next attempt succeeds as - // soon as the oracle posts a fresh round. | "OracleStale" - // Oracle returned a non-positive answer — same retry semantics. | "InvalidOracle"; const RECOVERABLE_REVERTS = new Set([ @@ -993,27 +697,6 @@ function decodeRecoverableRevert(err: unknown): RecoverableRevert | undefined { : undefined; } -/** - * Tx-submission errors that mean "the broadcast didn't take, try again - * next sweep" rather than "the call would revert". We treat these as - * recoverable so a transient mempool / nonce / RPC issue doesn't crash - * the keeper via unhandled rejection on a setTimeout-fired path. - * - * Patterns we've actually seen in production logs (all `code: -32000` - * from Alchemy / Geth-flavoured nodes): - * - "replacement transaction underpriced" — same nonce already in - * mempool (e.g. concurrent manual `cast send`, or stale tx from a - * previous keeper run) - * - "nonce too low" — node just reflected the previous tx, our cached - * nonce is stale - * - "already known" — same tx hash already pending - * - "transaction underpriced" — new tx below current minGasPrice - * - generic timeout / 5xx / network errors - * - * Match by message substring because viem flattens RPC errors into - * `BaseError.shortMessage` / `details` and there's no stable code we can - * key off of across providers. - */ function isTransientTxError(err: unknown): boolean { if (!(err instanceof Error)) return false; const haystack = @@ -1026,7 +709,7 @@ function isTransientTxError(err: unknown): boolean { haystack.includes("nonce too low") || haystack.includes("already known") || haystack.includes("known transaction") || - haystack.includes("could not coalesce") || // node-side mempool flap + haystack.includes("could not coalesce") || haystack.includes("timeout") || haystack.includes("econnreset") || haystack.includes("etimedout") || @@ -1034,4 +717,4 @@ function isTransientTxError(err: unknown): boolean { ); } -export const __testing = { decodeRecoverableRevert, isTransientTxError }; +export const __testing = { decodeRecoverableRevert, isTransientTxError, trackKey }; diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index 67b862e..6f432fa 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -18,7 +18,7 @@ import { FuturesAbi as futuresAbi } from "futures-marketplace-abi/Futures.ts"; * * - Vault Deposited / Withdrawn / Transfer → adds users on first deposit * - Perps OrderCreated / OrderMatched / PositionLiquidated - * - Futures OrderCreated / LotCreated / LotLiquidated + * - Futures OrderCreated / OrderMatched / PositionLiquidated * * On startup, `backfill(fromBlock)` scans the same six events historically * via `getLogs` so the cold-start window doesn't miss participants who @@ -106,8 +106,8 @@ export class ParticipantTracker { this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, abi: futuresAbi, - eventName: "LotCreated", - onLogs: (logs) => this.onFuturesLotCreated(logs), + eventName: "OrderMatched", + onLogs: (logs) => this.onFuturesOrderMatched(logs), }), ); } @@ -134,7 +134,7 @@ export class ParticipantTracker { * dedupes on checksum. * * Futures has no `getUsersWithPositions` view on-chain, so historical - * `OrderCreated` / `LotCreated` logs are the only source of cold- + * `OrderCreated` / `OrderMatched` logs are the only source of cold- * start participants. Perps has the view but we use logs uniformly so a * single backfill mechanism covers both venues (and the vault). * @@ -245,16 +245,16 @@ export class ParticipantTracker { }, }, { - label: "futures.LotCreated", + label: "futures.OrderMatched", run: async (from, to) => { const logs = await this.chain.publicClient.getContractEvents({ address: this.config.futures.address, abi: futuresAbi, - eventName: "LotCreated", + eventName: "OrderMatched", fromBlock: from, toBlock: to, }); - this.onFuturesLotCreated(logs as unknown as readonly Log[]); + this.onFuturesOrderMatched(logs as unknown as readonly Log[]); }, }, ]; @@ -353,7 +353,7 @@ export class ParticipantTracker { * Subscribe to "user state may have changed" events. Fires for the same * triggers `onAdded` does, plus any time a tracked user's state could * have shifted (vault transfer in/out, perps OrderCreated/Matched, - * futures OrderCreated/LotCreated). + * futures OrderCreated/OrderMatched). * * The predictive layer uses this to invalidate and rebuild a user's * cached MM snapshot. Listeners must tolerate being called for users @@ -462,8 +462,7 @@ export class ParticipantTracker { /** * `OrderCreated(bytes32 indexed orderId, address indexed participant, - * string destURL, uint256 pricePerDay, uint256 deliveryAt, - * bool isBuy)`. + * uint256 price, int256 quantity, uint256 deliveryAt)`. */ private onFuturesOrderCreated(logs: readonly Log[]): void { type Args = { orderId?: Hex; participant?: Address }; @@ -474,16 +473,15 @@ export class ParticipantTracker { } /** - * `LotCreated(bytes32 indexed lotId, address indexed seller, - * address indexed buyer, uint256 pricePerDay, uint256 deliveryAt, ...)`. + * `OrderMatched(..., address indexed maker, address indexed taker, ...)`. */ - private onFuturesLotCreated(logs: readonly Log[]): void { - type Args = { lotId?: Hex; seller?: Address; buyer?: Address }; + private onFuturesOrderMatched(logs: readonly Log[]): void { + type Args = { maker?: Address; taker?: Address }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; if (args === undefined) continue; - if (args.seller !== undefined) this.touch(args.seller); - if (args.buyer !== undefined) this.touch(args.buyer); + if (args.maker !== undefined) this.touch(args.maker); + if (args.taker !== undefined) this.touch(args.taker); } } } diff --git a/keeper/src/index.ts b/keeper/src/index.ts index ddbfbb7..9c12e4a 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -185,7 +185,7 @@ async function main(): Promise { // log-backfill pipeline, so it survives RPC providers that cap // `eth_getLogs` block ranges (Alchemy free tier = 10 blocks). Without // this hook a position created before keeper boot would only ever be - // settled if log backfill happened to find its `LotCreated` + // settled if log backfill happened to find its `OrderMatched` // event, which is unreliable on rate-limited RPCs. if (deliveryCoordinator !== undefined) { void deliveryCoordinator.indexUserPositions(user); @@ -270,7 +270,7 @@ async function main(): Promise { // its own positions). Their positions may pre-date // BACKFILL_FROM_BLOCK, in which case the tracker has no // record of them — but we know the address at boot, so the - // one extra `getPositionIds` read is a free safety net. + // one extra `getActiveDeliveryDates` read is a free safety net. // 2. The manual seed list (`DELIVERY_BOOTSTRAP_USERS`). Used to // recover a known-stuck user when the tracker hasn't found // them — typical when log backfill is failing on the diff --git a/keeper/src/predict/mm.ts b/keeper/src/predict/mm.ts index 5e37f9a..5f21d5d 100644 --- a/keeper/src/predict/mm.ts +++ b/keeper/src/predict/mm.ts @@ -18,7 +18,7 @@ import type { AccountSnapshot, MMParams } from "./types.ts"; * - perp.orderMargin (constant) * - perp.unrealizedLoss = max(0, -((P - entry) * netQty / qtyScale)) * - futures.orderMargin (constant) - * - futures.unrealizedLoss = sum_i max(0, -(buyer? : ±)(P - entry_i)) + * - futures.unrealizedLoss = sum_i max(0, -(P * netQty_i - netEntryValue_i)) * - perp.fundingOwed (constant — short-term, refreshed on snapshot) * * Total mmRequired(P) is therefore piecewise-linear with kinks at the @@ -40,19 +40,18 @@ function abs(x: bigint): bigint { * Aggregate net delta in WAD (matches `_aggregateGreeks` for pure-delta). * * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals - * futuresDelta = sum_i (isBuyer ? +1 : -1) * WAD + * futuresDelta = sum_i netQuantity_i * WAD * * Note: the on-chain `getNetPositionDelta` already returns this sum for the * futures leg in WAD; we re-derive it here off-chain because the snapshot - * carries per-position rows (needed for per-leg PnL kinks) and re-using + * carries per-expiry aggregates (needed for per-leg PnL kinks) and re-using * them avoids a second contract call. Both paths converge on the same value. */ export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); let delta = (snap.perp.netQty * WAD) / perpQtyScale; for (const pos of snap.futures.positions) { - const sign = pos.isBuyer ? 1n : -1n; - delta += sign * WAD; + delta += pos.netQuantity * WAD; } return delta; } @@ -99,18 +98,16 @@ export function perpUnrealizedLoss(snap: AccountSnapshot, params: MMParams, P: b } /** - * Sum of per-position futures unrealized losses at price P. Each contract + * Sum of per-expiry futures unrealized losses at price P. Each whole contract * settles `pricePerDay` of notional (no duration multiplier): * - * diffPerDay = isBuyer ? (P - entryPerDay) : (entryPerDay - P) - * pnl = diffPerDay + * pnl = P * netQuantity - netEntryValue * loss = max(0, -pnl) */ export function futuresUnrealizedLoss(snap: AccountSnapshot, P: bigint): bigint { let sum = 0n; for (const pos of snap.futures.positions) { - const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay; + const pnl = P * pos.netQuantity - pos.netEntryValue; if (pnl < 0n) sum += -pnl; } return sum; diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index d62f35f..2a6b024 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -1,4 +1,4 @@ -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; @@ -56,9 +56,8 @@ export async function readMMParams( * function of price. Two RPC round-trips: * * 1. Bulk multicall: balance, perp position/orderMargin/funding, - * futures orderMargin/positionIds. - * 2. Per-position multicall: hydrate each futures position so we know its - * `(buyer, buyPricePerDay, sellPricePerDay)` for off-chain PnL. + * futures orderMargin/activeDeliveryDates. + * 2. Per-expiry multicall: hydrate each aggregate via `getUserPosition`. * * Round-trip 2 collapses to zero calls when the user has no futures * positions (the common case for perps-only users). @@ -74,7 +73,7 @@ export async function readAccountSnapshot( perpOrderMargin, perpFunding, futuresOrderMargin, - futuresPositionIds, + activeDeliveryDates, ] = await chain.publicClient.multicall({ contracts: [ { @@ -110,36 +109,34 @@ export async function readAccountSnapshot( { address: config.futures.address, abi: FuturesAbi, - functionName: "getPositionIds" as const, + functionName: "getActiveDeliveryDates" as const, args: [user] as const, }, ] as const, allowFailure: false, }); - const positionIds = futuresPositionIds as readonly Hex[]; + const deliveryAts = activeDeliveryDates as readonly bigint[]; const futuresPositions: AccountSnapshot["futures"]["positions"] = []; - if (positionIds.length > 0) { + if (deliveryAts.length > 0) { const positions = await chain.publicClient.multicall({ - contracts: positionIds.map((id) => ({ + contracts: deliveryAts.map((deliveryAt) => ({ address: config.futures.address, abi: FuturesAbi, - functionName: "getPositionById" as const, - args: [id] as const, + functionName: "getUserPosition" as const, + args: [user, deliveryAt] as const, })), allowFailure: false, }); - const userLower = user.toLowerCase(); - for (let i = 0; i < positionIds.length; i++) { + for (let i = 0; i < deliveryAts.length; i++) { const pos = positions[i]; - const id = positionIds[i]; - if (pos === undefined || id === undefined) continue; - const isBuyer = pos.buyer.toLowerCase() === userLower; + const deliveryAt = deliveryAts[i]; + if (pos === undefined || deliveryAt === undefined) continue; + if (pos.netQuantity === 0n) continue; futuresPositions.push({ - id, - isBuyer, - entryPricePerDay: isBuyer ? pos.buyPricePerDay : pos.sellPricePerDay, - deliveryAt: pos.deliveryAt, + deliveryAt, + netQuantity: pos.netQuantity, + netEntryValue: pos.netEntryValue, }); } } diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index 01ef95e..731dba2 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -1,7 +1,17 @@ -import type { Hex } from "viem"; -import type { AccountSnapshot, AlertThresholds, MMParams, PriceThresholds } from "./types.ts"; +import type { AccountSnapshot, AlertThresholds, FuturesCloseLeg, MMParams, PriceThresholds } from "./types.ts"; import { imRequired, imSurplus, mmSurplus } from "./mm.ts"; +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** Average entry price for an aggregate (`|netEntryValue| / |netQuantity|`). */ +function avgEntry(pos: AccountSnapshot["futures"]["positions"][number]): bigint { + const absNet = abs(pos.netQuantity); + if (absNet === 0n) return 0n; + return abs(pos.netEntryValue) / absNet; +} + /** * Find the price thresholds where `mmSurplus(P)` crosses zero. * @@ -118,7 +128,7 @@ function findClosestCrossings( const kinks: bigint[] = []; if (snap.perp.netQty !== 0n) kinks.push(snap.perp.entryPrice); for (const pos of snap.futures.positions) { - kinks.push(pos.entryPricePerDay); + if (pos.netQuantity !== 0n) kinks.push(avgEntry(pos)); } kinks.push(currentPrice); kinks.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); @@ -172,45 +182,61 @@ function findClosestCrossings( // Close-to-IM-buffer sizing (the batched-liquidation solvers) // // The on-chain `liquidatePositions` (futures) / `liquidatePosition(user, -// closeQty)` (perps) do NOT recompute margin per lot — they close the +// closeQty)` (perps) do NOT recompute margin per unit — they close the // keeper-supplied amount and enforce a single end-of-tx `OverLiquidation` // guard: with positions remaining and a real IM buffer (`im > mm`), the // leftover balance must sit at/under IM. These solvers pick, off-chain, the // deepest close that keeps the account inside the `[MM, IM]` band (healthy but -// not over-liquidated) — so one batched tx replaces the old one-lot-per-tx -// churn. If no in-band partial exists (deep crash / bad debt) they fall back -// to a full close, which the contract lets through (the guard is skipped once -// no positions remain). +// not over-liquidated). If no in-band partial exists (deep crash / bad debt) +// they fall back to a full close, which the contract lets through (the guard +// is skipped once no positions remain). // ─────────────────────────────────────────────────────────────────────────── /** - * Off-chain replica of the futures batch close: remove `closeIds` from the - * snapshot and debit the realized PnL + flat fee of each closed lot from the - * balance. Mirrors `Futures._forceLiquidatePosition` (loss/profit routed - * through the insurance fund) + the per-lot `liquidationFee`. Entry prices of - * the surviving lots are untouched. Shared by the solver and its tests so the - * band predicate they assert is the exact one the solver optimises against. + * Off-chain replica of the futures batch close: reduce each aggregate toward + * zero by `closeQty` and debit realized PnL + flat fee per expiry leg. + * Mirrors `Futures._doPartialLiquidatePosition` / `_doLiquidateFullPosition`. */ export function simulateFuturesClose( snap: AccountSnapshot, - closeIds: readonly Hex[], + closes: readonly FuturesCloseLeg[], currentPrice: bigint, liquidationFee: bigint, ): AccountSnapshot { - const closeSet = new Set(closeIds); + const closeByExpiry = new Map(); + for (const c of closes) { + closeByExpiry.set(c.deliveryAt, (closeByExpiry.get(c.deliveryAt) ?? 0n) + c.closeQty); + } + const remaining: AccountSnapshot["futures"]["positions"] = []; let balanceDelta = 0n; for (const pos of snap.futures.positions) { - if (!closeSet.has(pos.id)) { + const want = closeByExpiry.get(pos.deliveryAt) ?? 0n; + if (want <= 0n) { remaining.push(pos); continue; } - const diffPerDay = pos.isBuyer - ? currentPrice - pos.entryPricePerDay - : pos.entryPricePerDay - currentPrice; - const pnl = diffPerDay; + const absNet = abs(pos.netQuantity); + const closeAbs = want < absNet ? want : absNet; + if (closeAbs <= 0n) { + remaining.push(pos); + continue; + } + + const entry = avgEntry(pos); + const signedClose = pos.netQuantity > 0n ? closeAbs : -closeAbs; + const pnl = (currentPrice - entry) * signedClose; balanceDelta += pnl - liquidationFee; + + if (closeAbs >= absNet) continue; + const newAbs = absNet - closeAbs; + remaining.push({ + deliveryAt: pos.deliveryAt, + netQuantity: pos.netQuantity > 0n ? newAbs : -newAbs, + netEntryValue: (pos.netEntryValue * newAbs) / absNet, + }); } + return { ...snap, balance: snap.balance + balanceDelta, @@ -250,61 +276,57 @@ export function simulatePerpClose( } /** - * Pick the worst-first subset of futures lot ids to close so the account lands - * inside the `[MM, IM]` band. Lots are ranked by unrealized loss (desc), then - * notional (desc). We add lots one at a time (simulating each removal) and keep - * the DEEPEST prefix that is healthy at MM while staying at/under IM. When a - * real IM buffer exists (`imSpotShock > mmSpotShock`), closing past the IM - * crossing would trip `OverLiquidation`, so we stop there. In the degenerate - * `IM == MM` case there is no upper bound — we take the minimal healthy prefix. - * Returns `[]` if already healthy, or every id (full close) when no in-band - * partial exists (deep crash / bad debt — the contract skips the guard once - * the position set is empty). + * Pick per-expiry `closeQty` legs so the account lands inside the `[MM, IM]` + * band. Unit closes are ranked worst-first and interleaved across expiries + * (round-robin) so a prefix does not drain one book before touching another. + * Returns `[]` if already healthy, or a full close of every aggregate when no + * in-band partial exists (deep crash / bad debt). */ -export function solveFuturesLotsToTarget( +export function solveFuturesClosesToTarget( snap: AccountSnapshot, params: MMParams, currentPrice: bigint, liquidationFee: bigint, -): Hex[] { +): FuturesCloseLeg[] { const positions = snap.futures.positions; if (positions.length === 0) return []; if (mmSurplus(snap, params, currentPrice) >= 0n) return []; const hasBuffer = params.imSpotShock > params.mmSpotShock; - - // Expiry-balanced worst-first ordering. Each `deliveryAt` is a separate - // market/order-book, so we interleave closures across expirations (round - // robin, worst-first within each) instead of a single global worst-first - // prefix that would drain one expiry's book before touching another. The - // batch is still submitted in one `liquidatePositions` tx; balancing only - // shapes WHICH lots that tx closes. The prefix search below is unchanged, so - // we still stop at the deepest in-band subset (reaching IM stays the - // priority — balance is best-effort within that). - const ranked = rankLotsBalancedAcrossExpirations(positions, currentPrice); - - const n = ranked.length; - let best: Hex[] | undefined; - for (let k = 1; k < n; k++) { - const closeSet = ranked.slice(0, k).map((p) => p.id); - const after = simulateFuturesClose(snap, closeSet, currentPrice, liquidationFee); + const unitSequence = rankUnitClosesBalancedAcrossExpirations(positions, currentPrice); + const n = unitSequence.length; + if (n === 0) return []; + + let bestPrefix = 0; + let foundInBand = false; + for (let k = 1; k <= n; k++) { + const closes = coalesceUnitPrefix(unitSequence, k); + const after = simulateFuturesClose(snap, closes, currentPrice, liquidationFee); const mmS = mmSurplus(after, params, currentPrice); const imS = imSurplus(after, params, currentPrice); if (!hasBuffer) { - // Degenerate IM == MM: no over-liquidation ceiling. Take minimal healthy. if (mmS >= 0n) { - best = closeSet; + bestPrefix = k; + foundInBand = true; break; } continue; } - if (mmS >= 0n && imS <= 0n) best = closeSet; // in band — record and keep going deeper - if (imS > 0n) break; // deeper only raises IM surplus → would over-liquidate + if (mmS >= 0n && imS <= 0n) { + bestPrefix = k; + foundInBand = true; + } + if (imS > 0n) break; } - if (best !== undefined) return best; - // No in-band partial — close everything (bad-debt / full-deleverage path). - return ranked.map((p) => p.id); + if (!foundInBand) { + // Full close every aggregate. + return positions.map((p) => ({ + deliveryAt: p.deliveryAt, + closeQty: abs(p.netQuantity), + })); + } + return coalesceUnitPrefix(unitSequence, bestPrefix); } /** @@ -372,75 +394,61 @@ function firstQtyWhere(f: (q: bigint) => bigint, hi: bigint): bigint { return b; } -type FuturesLot = AccountSnapshot["futures"]["positions"][number]; +type FuturesAggregate = AccountSnapshot["futures"]["positions"][number]; /** - * Order futures lots so a worst-first prefix is *balanced across expirations*. - * - * Lots are grouped by `deliveryAt` (each group = one market). Within a group - * they are sorted worst-first (unrealized loss desc, then notional desc, then - * id for determinism). Groups are then round-robin interleaved — round `r` - * takes the r-th lot of every group that still has one — with groups visited - * worst-first (group total loss desc, tiebreak `deliveryAt` asc). - * - * The effect: any prefix of the result draws from every expiry evenly until a - * book is exhausted, so the deepest in-band prefix spreads the close rather - * than emptying a single expiry's book. A single-expiry portfolio collapses to - * plain worst-first (identical to the pre-balancing behaviour). + * Expand aggregates into a unit-close sequence interleaved across expiries. + * Each unit is one whole contract at a `deliveryAt`. Groups (expiries) are + * ordered by total unrealized loss desc; within the sequence we round-robin + * one unit from each group until books are exhausted. */ -function rankLotsBalancedAcrossExpirations( - positions: readonly FuturesLot[], +function rankUnitClosesBalancedAcrossExpirations( + positions: readonly FuturesAggregate[], currentPrice: bigint, -): FuturesLot[] { - const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, currentPrice); - const notionalOf = (p: FuturesLot) => p.entryPricePerDay; - - const groups = new Map(); - for (const p of positions) { - const bucket = groups.get(p.deliveryAt); - if (bucket === undefined) groups.set(p.deliveryAt, [p]); - else bucket.push(p); - } - - const worstFirst = (a: FuturesLot, b: FuturesLot): number => { - const la = lossOf(a); - const lb = lossOf(b); - if (la !== lb) return la < lb ? 1 : -1; - const na = notionalOf(a); - const nb = notionalOf(b); - if (na !== nb) return na < nb ? 1 : -1; - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; - }; - for (const bucket of groups.values()) bucket.sort(worstFirst); - - const orderedGroups = [...groups.entries()] - .sort(([dateA, groupA], [dateB, groupB]) => { - const lossA = groupA.reduce((s, p) => s + lossOf(p), 0n); - const lossB = groupB.reduce((s, p) => s + lossOf(p), 0n); - if (lossA !== lossB) return lossA < lossB ? 1 : -1; - return dateA < dateB ? -1 : dateA > dateB ? 1 : 0; - }) - .map(([, group]) => group); - - const result: FuturesLot[] = []; - let maxLen = 0; - for (const group of orderedGroups) if (group.length > maxLen) maxLen = group.length; - for (let round = 0; round < maxLen; round++) { - for (const group of orderedGroups) { - const lot = group[round]; - if (lot !== undefined) result.push(lot); +): bigint[] { + const lossOf = (p: FuturesAggregate) => aggregateUnrealizedLoss(p, currentPrice); + const ordered = [...positions] + .filter((p) => p.netQuantity !== 0n) + .sort((a, b) => { + const la = lossOf(a); + const lb = lossOf(b); + if (la !== lb) return la < lb ? 1 : -1; + const na = abs(a.netQuantity) * avgEntry(a); + const nb = abs(b.netQuantity) * avgEntry(b); + if (na !== nb) return na < nb ? 1 : -1; + return a.deliveryAt < b.deliveryAt ? -1 : a.deliveryAt > b.deliveryAt ? 1 : 0; + }); + + const remaining = ordered.map((p) => abs(p.netQuantity)); + const result: bigint[] = []; + let progress = true; + while (progress) { + progress = false; + for (let i = 0; i < ordered.length; i++) { + const left = remaining[i] ?? 0n; + if (left <= 0n) continue; + remaining[i] = left - 1n; + result.push(ordered[i]!.deliveryAt); + progress = true; } } return result; } -/** Per-lot unrealized loss at `P` (token decimals); 0 when in profit. */ -function lotUnrealizedLoss( - pos: AccountSnapshot["futures"]["positions"][number], - P: bigint, -): bigint { - const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay; +function coalesceUnitPrefix(unitSequence: readonly bigint[], prefixLen: number): FuturesCloseLeg[] { + const counts = new Map(); + for (let i = 0; i < prefixLen && i < unitSequence.length; i++) { + const d = unitSequence[i]!; + counts.set(d, (counts.get(d) ?? 0n) + 1n); + } + return [...counts.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([deliveryAt, closeQty]) => ({ deliveryAt, closeQty })); +} + +/** Per-aggregate unrealized loss at `P` (token decimals); 0 when in profit. */ +function aggregateUnrealizedLoss(pos: FuturesAggregate, P: bigint): bigint { + const pnl = P * pos.netQuantity - pos.netEntryValue; return pnl < 0n ? -pnl : 0n; } diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts index 1d5b201..ede6ba5 100644 --- a/keeper/src/predict/types.ts +++ b/keeper/src/predict/types.ts @@ -1,4 +1,4 @@ -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; /** * Per-account inputs needed to evaluate `mmRequired(P)` and `imRequired(P)` @@ -7,7 +7,7 @@ import type { Address, Hex } from "viem"; * * Shapes deliberately mirror the on-chain getters: * - perps: `getUserPosition` + `getOrderMargin` + `getPendingFunding` - * - futures: `getPositionIds`/`getPositionById` + `getFuturesOrderMargin` + * - futures: `getActiveDeliveryDates`/`getUserPosition` + `getFuturesOrderMargin` * * Bigints throughout because PME math is performed in token-decimal units * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers @@ -31,25 +31,18 @@ export interface AccountSnapshot { }; /** - * One entry per active futures position. Each contract is a single unit that - * settles `pricePerDay` of notional (no duration multiplier); PnL accrues - * `(P_perDay - entryPricePerDay)` from the holder's perspective (`+` for - * buyers, `−` for sellers). + * One entry per active futures expiry. Unilateral aggregate per + * `(user, deliveryAt)`: signed `netQuantity` (whole contracts) + + * `netEntryValue` (token decimals) so unrealized PnL is + * `P * netQuantity - netEntryValue`. */ futures: { positions: Array<{ - id: Hex; - isBuyer: boolean; - /** Token decimals. */ - entryPricePerDay: bigint; - /** - * Expiration timestamp (unix seconds) this lot delivers at. Lots sharing - * a `deliveryAt` are the same market/order-book; the liquidation solver - * groups on it to balance closures across expirations rather than - * draining one expiry's book. It does NOT affect PnL/margin math — every - * lot contributes a single unit. - */ deliveryAt: bigint; + /** Signed whole contracts (+long / −short). */ + netQuantity: bigint; + /** Token decimals; `sum(fillPrice * signedFillQty)`. */ + netEntryValue: bigint; }>; /** Constant in P: `getFuturesOrderMargin(user)`. */ orderMargin: bigint; @@ -97,3 +90,10 @@ export interface AlertThresholds { critDown: bigint | undefined; critUp: bigint | undefined; } + +/** One expiry leg of a futures close-to-IM batch. */ +export interface FuturesCloseLeg { + deliveryAt: bigint; + /** Absolute contracts to close toward zero (≤ |netQuantity|). */ + closeQty: bigint; +} diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts index 03b31e9..3346486 100644 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -37,8 +37,8 @@ import type { ParticipantTracker } from "../discovery/tracker.ts"; * Hot path: * * tick → for each tracked user: - * 1. readContract `getOrderIds(user)` — empty? skip - * 2. multicall `getOrderById(id)` for each id → filter expired + * 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). @@ -203,13 +203,13 @@ export class OutdatedOrderSweeper { orderIds = (await this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getOrderIds", + functionName: "getUserOrders", args: [user], })) as readonly Hex[]; } catch (err) { this.logger.warn( { err, user }, - "getOrderIds failed — skipping user this sweep", + "getUserOrders failed — skipping user this sweep", ); continue; } @@ -221,7 +221,7 @@ export class OutdatedOrderSweeper { contracts: orderIds.map((id) => ({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getOrderById" as const, + functionName: "getOrder" as const, args: [id] as const, })), allowFailure: false, @@ -229,7 +229,7 @@ export class OutdatedOrderSweeper { } catch (err) { this.logger.warn( { err, user, orderCount: orderIds.length }, - "multicall(getOrderById) failed — skipping user this sweep", + "multicall(getOrder) failed — skipping user this sweep", ); continue; } diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 550212e..840522a 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -1,11 +1,11 @@ -import { getAddress, pad, toHex, type Address, type Hex } from "viem"; +import { pad, toHex, 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 { sendLiquidate } from "../tx/liquidate.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; -import { solveFuturesLotsToTarget } from "../predict/solve.ts"; +import { solveFuturesClosesToTarget } from "../predict/solve.ts"; import type { MMParams } from "../predict/types.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { @@ -18,11 +18,11 @@ import type { } from "./types.ts"; /** - * `Venue` adapter for the Futures contract. + * `Venue` adapter for the Futures contract (3.0 aggregate positions). * - * One matched unit settles `pricePerDay` of notional (there is no duration - * multiplier). Position PnL is therefore just `priceDiffPerDay` per contract, - * mirroring `getFuturesUnrealizedPnl` on-chain. + * One matched unit settles `pricePerDay` of notional (no duration multiplier). + * Position PnL is `mark * netQuantity - netEntryValue`, matching on-chain + * settle/liquidate math. */ export class FuturesVenue implements Venue { readonly name = "futures" as const; @@ -42,16 +42,11 @@ export class FuturesVenue implements Venue { this.chain = chain; this.config = config; this.logger = logger.child({ venue: "futures" }); - // Optional — when present every confirmed-tx log gets `gasCostUsd` - // alongside `gasCostEth`. Wiring keeps the field absent (rather than - // zero) when the feed is unset so log search can distinguish "feed - // off" from a literal zero-cost tx. this.ethUsdFeed = ethUsdFeed; } marketLabel(marketId: MarketId): string { const deliveryAt = marketIdToDeliveryAt(marketId); - // Render as ISO date so on-call alerts read naturally. const iso = new Date(Number(deliveryAt) * 1000).toISOString().slice(0, 10); return `futures ${iso}`; } @@ -60,20 +55,17 @@ export class FuturesVenue implements Venue { const orderIds = (await this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getOrderIds", + functionName: "getUserOrders", args: [user], })) as readonly Hex[]; if (orderIds.length === 0) return []; - // Hydrate each order so we know its `deliveryAt` (== marketId). The - // contract sweeps FIFO regardless, but the planner wants per-market - // labelling for alerts and ranking. const orders = await this.chain.publicClient.multicall({ contracts: orderIds.map((id) => ({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getOrderById" as const, + functionName: "getOrder" as const, args: [id] as const, })), allowFailure: false, @@ -86,13 +78,13 @@ export class FuturesVenue implements Venue { } async readPositions(user: Address): Promise { - const [positionIds, marketPrice] = await Promise.all([ + const [deliveryAts, marketPrice] = await Promise.all([ this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getPositionIds", + functionName: "getActiveDeliveryDates", args: [user], - }) as Promise, + }) as Promise, this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, @@ -100,50 +92,44 @@ export class FuturesVenue implements Venue { }) as Promise, ]); - if (positionIds.length === 0) return []; + if (deliveryAts.length === 0) return []; const positions = await this.chain.publicClient.multicall({ - contracts: positionIds.map((id) => ({ + contracts: deliveryAts.map((deliveryAt) => ({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getPositionById" as const, - args: [id] as const, + functionName: "getUserPosition" as const, + args: [user, deliveryAt] as const, })), allowFailure: false, }); - const userAddr = getAddress(user); - return positionIds.map((id, i) => { - const pos = positions[i]; - // Each position is a single contract that settles `pricePerDay` of notional - // (no duration multiplier), matching `getFuturesUnrealizedPnl` on-chain. - const isBuyer = getAddress(pos.buyer) === userAddr; - const entryPricePerDay = isBuyer - ? pos.buyPricePerDay - : pos.sellPricePerDay; - const priceDiffPerDay = isBuyer - ? marketPrice - entryPricePerDay // long: lose when market drops - : entryPricePerDay - marketPrice; // short: lose when market rises - const pnl = priceDiffPerDay; + const out: VenuePosition[] = []; + for (let i = 0; i < deliveryAts.length; i++) { + const deliveryAt = deliveryAts[i]!; + const pos = positions[i]!; + if (pos.netQuantity === 0n) continue; + + const absQty = pos.netQuantity < 0n ? -pos.netQuantity : pos.netQuantity; + const pnl = marketPrice * pos.netQuantity - pos.netEntryValue; const unrealizedLoss = pnl < 0n ? -pnl : 0n; - const notional = entryPricePerDay; + const avgEntry = abs(pos.netEntryValue) / absQty; + const notional = avgEntry * absQty; - return { - id, - marketId: deliveryAtMarketId(pos.deliveryAt), + out.push({ + id: deliveryAtMarketId(deliveryAt), + marketId: deliveryAtMarketId(deliveryAt), unrealizedLoss, notional, - }; - }); + }); + } + return out; } async liquidateOrders( user: Address, _ids?: readonly Hex[], ): Promise { - // Futures sweeps FIFO until the participant is healthy — no calldata id - // list needed. We deliberately ignore `ids` rather than asserting on it - // so the venue surface stays uniform across perps/futures. const result = await sendLiquidate({ chain: this.chain, config: this.config, @@ -162,8 +148,6 @@ export class FuturesVenue implements Venue { } async reduceToTarget(user: Address): Promise { - // Size the worst-first lot subset off-chain against a fresh snapshot so the - // account lands inside the [MM, IM] band (or a full close on a deep crash). const [snapshot, params, marketPrice] = await Promise.all([ readAccountSnapshot(this.chain, this.config, user), this.getMMParams(), @@ -174,33 +158,29 @@ export class FuturesVenue implements Venue { }) as Promise, ]); - // The contract's liquidation-fee payout is disabled, so each closed lot realizes - // no fee — pass 0 to the solver so its balance projection matches on-chain reality. - const ids = solveFuturesLotsToTarget(snapshot, params, marketPrice, 0n); - if (ids.length === 0) { - // Off-chain sizing says the account is already at/above the IM buffer. + // Liquidation-fee payout is disabled on-chain — pass 0 so the projection matches. + const closes = solveFuturesClosesToTarget(snapshot, params, marketPrice, 0n); + if (closes.length === 0) { return { skipped: "nothingToClose" }; } - // Gas-bounded chunking ("Option A"): send at most `maxLotsPerLiquidationTx` - // of the worst-first ids in this batch. `ids` is already ordered - // worst-first (highest unrealized loss), and a chunk shorter than the - // solver's full target closes FEWER lots than needed — so the leftover - // balance stays below IM and the on-chain `OverLiquidation` guard can't - // trip. The planner loop re-invokes `reduceToTarget` on a fresh snapshot to - // drain the remaining lots across successive txs (adapting to price drift). + // Gas-bounded chunking: send at most `maxLotsPerLiquidationTx` expiry legs. const cap = this.config.futures.maxLotsPerLiquidationTx; - const chunk = cap > 0 && ids.length > cap ? ids.slice(0, cap) : ids; + const chunk = cap > 0 && closes.length > cap ? closes.slice(0, cap) : closes; + const deliveryAts = chunk.map((c) => c.deliveryAt); + const closeQtys = chunk.map((c) => c.closeQty); + const contractsClosed = closeQtys.reduce((s, q) => s + q, 0n); this.logger.info( { user, - lotsInChunk: chunk.length, - lotsToClose: ids.length, - ofTotal: snapshot.futures.positions.length, - chunked: chunk.length < ids.length, + legsInChunk: chunk.length, + legsToClose: closes.length, + contractsClosed: contractsClosed.toString(), + ofExpiries: snapshot.futures.positions.length, + chunked: chunk.length < closes.length, }, - "Futures reduceToTarget: closing worst-first lot chunk in one batch", + "Futures reduceToTarget: closing worst-first expiry chunk", ); const result = await sendLiquidate({ @@ -210,8 +190,8 @@ export class FuturesVenue implements Venue { address: this.config.futures.address, abi: FuturesAbi, functionName: "liquidatePositions", - args: [user, chunk], - feeEventName: "LotLiquidated", + args: [user, deliveryAts, closeQtys], + feeEventName: "PositionLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; return "notLiquidatable"; @@ -221,16 +201,18 @@ export class FuturesVenue implements Venue { return "skipped" in result ? { skipped: result.skipped } - : { feeEarned: result.feeEarned, positionsClosed: chunk.length }; + : { feeEarned: result.feeEarned, positionsClosed: Number(contractsClosed) }; } - /** Read + cache the PME engine params (shocks / decimals). Immutable per epoch. */ private async getMMParams(): Promise { if (this.mmParams !== undefined) return this.mmParams; this.mmParams = await readMMParams(this.chain, this.config); return this.mmParams; } +} +function abs(x: bigint): bigint { + return x < 0n ? -x : x; } /** `bytes32(uint256(deliveryAt))` — same encoding the indexer uses. */ diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index b28e95d..97d7832 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -93,11 +93,11 @@ export interface Venue { * * 1. Read a fresh account snapshot + engine params. * 2. Size the worst-first close off-chain so the account lands inside the - * `[MM, IM]` band (futures: a lot-id subset; perps: a partial + * `[MM, IM]` band (futures: per-expiry `closeQty` legs; perps: a partial * `closeQty`). Deep-underwater accounts with no in-band partial size to * a full close. - * 3. Submit ONE tx — futures `liquidatePositions(user, ids[])`, perps - * `liquidatePosition(user, closeQty)`. + * 3. Submit ONE tx — futures `liquidatePositions(user, deliveryAts[], + * closeQtys[])`, perps `liquidatePosition(user, closeQty)`. * * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first) or * `OverLiquidation` (a price race made the sizing overshoot IM) are diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index f4d8981..b177d9e 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -3,10 +3,8 @@ import assert from "node:assert/strict"; import { BaseError, ContractFunctionRevertedError, - parseAbi, type Address, type Hex, - type Log, type TransactionReceipt, } from "viem"; import type pino from "pino"; @@ -15,13 +13,10 @@ import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; -const VALIDATOR = "0x0000000000000000000000000000000000005a1d" as Address; -const SELLER = "0x0000000000000000000000000000000000005e11" as Address; -const BUYER = "0x0000000000000000000000000000000000000b0b" as Address; - -const POSITION_A: Hex = `0x${"a".repeat(64)}`; -const POSITION_B: Hex = `0x${"b".repeat(64)}`; -const POSITION_C: Hex = `0x${"c".repeat(64)}`; +const USER_A = "0x0000000000000000000000000000000000000b0b" as Address; +const USER_B = "0x0000000000000000000000000000000000005e11" as Address; +const DELIVERY_A = 1_756_416_000n; +const DELIVERY_B = 1_759_008_000n; const silentLogger = { child: () => silentLogger, @@ -31,29 +26,6 @@ const silentLogger = { error: () => undefined, } as unknown as pino.Logger; -interface LogCall { - level: "debug" | "info" | "warn" | "error"; - obj: Record; - msg: string; -} - -/** Capturing logger for tests that assert on log severity / messages. */ -function makeRecordingLogger(): { logger: pino.Logger; calls: LogCall[] } { - const calls: LogCall[] = []; - const make = (level: LogCall["level"]) => (obj: Record, msg?: string) => { - if (typeof obj === "string") calls.push({ level, obj: {}, msg: obj }); - else calls.push({ level, obj, msg: msg ?? "" }); - }; - const logger = { - child: () => logger, - debug: make("debug"), - info: make("info"), - warn: make("warn"), - error: make("error"), - } as unknown as pino.Logger; - return { logger, calls }; -} - function makeConfig(overrides: Partial = {}): Config { return { futures: { address: FUTURES }, @@ -70,147 +42,114 @@ function makeConfig(overrides: Partial = {}): Config { } as Config; } -interface LotCreatedLog { +interface MatchedLog { args: { - lotId: Hex; - seller: Address; - buyer: Address; + maker: Address; + taker: Address; deliveryAt: bigint; + makerNetQtyAfter: bigint; + takerNetQtyAfter: bigint; }; } -interface LotClosedLog { - args: { lotId: Hex }; +interface SettledLog { + args: { user: Address; deliveryAt: bigint }; } -function lotCreatedLog(lotId: Hex, deliveryAt: bigint): LotCreatedLog { - return { args: { lotId, seller: SELLER, buyer: BUYER, deliveryAt } }; +function matchedLog( + maker: Address, + taker: Address, + deliveryAt: bigint, + makerQty: bigint, + takerQty: bigint, +): MatchedLog { + return { + args: { + maker, + taker, + deliveryAt, + makerNetQtyAfter: makerQty, + takerNetQtyAfter: takerQty, + }, + }; } -function lotClosedLog(lotId: Hex): LotClosedLog { - return { args: { lotId } }; +function settledLog(user: Address, deliveryAt: bigint): SettledLog { + return { args: { user, deliveryAt } }; } interface ChainStubOptions { - /** Fixed `block.timestamp`-style number returned by `getBlockNumber`. */ blockNumber?: bigint; - /** Recorded calls to `simulateContract`. The handler is per-call so tests can vary outcomes. */ + blockTimestamp?: bigint; simulate?: (args: readonly unknown[]) => { request: { ok: true } } | { error: unknown }; - writeHash?: `0x${string}`; + writeHash?: Hex; receipt?: TransactionReceipt; - /** Captures live event subscriptions so a test can flush manual logs into them. */ watchers?: { - lotCreated?: (logs: readonly LotCreatedLog[]) => void; - lotClosed?: (logs: readonly LotClosedLog[]) => void; + orderMatched?: (logs: readonly MatchedLog[]) => void; + positionSettled?: (logs: readonly SettledLog[]) => void; }; - /** - * Historical logs returned by `getContractEvents`, keyed by event name. - * Same logs are returned for every chunk — tests use a single chunk that - * covers the whole window, so a per-chunk dispatcher is overkill here. - */ history?: { - LotCreated?: LotCreatedLog[]; - LotClosed?: LotClosedLog[]; + OrderMatched?: MatchedLog[]; + PositionSettled?: SettledLog[]; }; - /** - * View-based discovery fixtures: per-user `getPositionIds` results and - * per-id `getPositionById` results, used by `bootstrapFromUsers` / - * `indexUserPositions`. Missing keys default to an empty list / - * `seller == 0` (already-closed) so a test can express "this user has - * no positions" or "this id is closed" without populating both maps. - */ - positionIdsByUser?: ReadonlyMap; - positionsById?: ReadonlyMap; - /** - * Override `Futures.validatorAddress()` returned by the chain stub. - * Defaults to `VALIDATOR` (matches the stub's signer) so existing tests - * pass `start()`'s pre-flight check transparently. - */ - validator?: Address; + activeDatesByUser?: Record; + positionsByUserDate?: Record; + readContractError?: (functionName: string) => Error | undefined; } -function makeChain(opts: ChainStubOptions = {}): Chain & { - calls: { writes: Array; simulates: Array }; -} { - const calls = { - writes: [] as Array, - simulates: [] as Array, - }; - const chain = { - account: { address: VALIDATOR } as { address: Address }, +function posKey(user: Address, deliveryAt: bigint): string { + return `${user.toLowerCase()}:${deliveryAt}`; +} + +function makeChain(opts: ChainStubOptions = {}): Chain { + const writeHash = opts.writeHash ?? ("0x" + "11".repeat(32) as Hex); + const receipt = opts.receipt ?? ({ + blockNumber: 1n, + gasUsed: 100_000n, + effectiveGasPrice: 1n, + status: "success", + } as unknown as TransactionReceipt); + + return { + account: { address: "0x0000000000000000000000000000000000009999" as Address }, publicClient: { - readContract: async ({ - functionName, - args, - }: { - functionName: string; - args?: readonly unknown[]; - }) => { - // start()'s pre-flight asserts the keeper signer == validator. Default - // matches `VALIDATOR` (the chain stub's account.address), so existing - // tests don't need to opt into anything. Set `validator: 0x...other` - // in opts to deliberately exercise the misalignment path. - if (functionName === "validatorAddress") return opts.validator ?? VALIDATOR; - if (functionName === "getPositionIds") { - const user = args?.[0] as Address | undefined; - if (user === undefined) throw new Error("getPositionIds called without user arg"); - return opts.positionIdsByUser?.get(user) ?? []; + getBlockNumber: async () => opts.blockNumber ?? 100n, + getBlock: async () => ({ + timestamp: opts.blockTimestamp ?? BigInt(Math.floor(Date.now() / 1000) + 10_000_000), + }), + readContract: async ({ functionName, args }: { functionName: string; args?: readonly unknown[] }) => { + const err = opts.readContractError?.(functionName); + if (err) throw err; + if (functionName === "getActiveDeliveryDates") { + const user = (args?.[0] as Address).toLowerCase(); + return opts.activeDatesByUser?.[user] ?? []; } throw new Error(`unexpected readContract: ${functionName}`); }, multicall: async ({ contracts, }: { - contracts: ReadonlyArray<{ functionName: string; args?: readonly unknown[] }>; + contracts: readonly { functionName: string; args?: readonly unknown[] }[]; }) => { return contracts.map((c) => { - if (c.functionName === "getPositionIds") { - const user = c.args?.[0] as Address; - return opts.positionIdsByUser?.get(user) ?? []; - } - if (c.functionName === "getPositionById") { - const id = c.args?.[0] as Hex; + if (c.functionName === "getUserPosition") { + const user = (c.args?.[0] as Address).toLowerCase(); + const deliveryAt = c.args?.[1] as bigint; + const key = `${user}:${deliveryAt}`; return ( - opts.positionsById?.get(id) ?? { - // `_removePosition` deletes the slot so closed/missing - // positions read back as the zero-initialized struct. - seller: "0x0000000000000000000000000000000000000000" as Address, - buyer: "0x0000000000000000000000000000000000000000" as Address, - deliveryAt: 0n, - } + opts.positionsByUserDate?.[key] ?? { netQuantity: 0n, netEntryValue: 0n } ); } - throw new Error(`unexpected multicall function: ${c.functionName}`); + if (c.functionName === "getActiveDeliveryDates") { + const user = (c.args?.[0] as Address).toLowerCase(); + return opts.activeDatesByUser?.[user] ?? []; + } + throw new Error(`unexpected multicall: ${c.functionName}`); }); }, - simulateContract: async (call: { args: readonly unknown[] }) => { - calls.simulates.push(call.args); - if (opts.simulate === undefined) throw new Error("test bug: simulate not configured"); - const out = opts.simulate(call.args); - if ("error" in out) throw out.error; - return out; - }, - writeContract: undefined, - waitForTransactionReceipt: async () => - opts.receipt ?? ({ blockNumber: 1n, logs: [] } as unknown as TransactionReceipt), - getBlockNumber: async () => opts.blockNumber ?? 0n, - // Sweep reads chain time from `getBlock().timestamp` (not Date.now) - // so it agrees with the contract's window guards. Default to wall - // clock so tests using `Date.now()`-derived `deliveryAt` still - // see the expected ordering. - getBlock: async () => ({ timestamp: BigInt(Math.floor(Date.now() / 1000)) }), - // Defaults so `withUnstickRetry` (used by `attemptBatch` to recover - // from `replacement transaction underpriced`) can run against the - // stub without exploding. `latest == pending` means "no stuck txs" - // → unstick is a no-op, then the original write retries. - getTransactionCount: async () => 0, - estimateFeesPerGas: async () => ({ - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 100_000_000n, - }), getContractEvents: async ({ eventName }: { eventName: string }) => { - if (eventName === "LotCreated") return opts.history?.LotCreated ?? []; - if (eventName === "LotClosed") return opts.history?.LotClosed ?? []; + if (eventName === "OrderMatched") return opts.history?.OrderMatched ?? []; + if (eventName === "PositionSettled") return opts.history?.PositionSettled ?? []; return []; }, watchContractEvent: ({ @@ -218,718 +157,182 @@ function makeChain(opts: ChainStubOptions = {}): Chain & { onLogs, }: { eventName: string; - onLogs: (logs: readonly Log[]) => void; + onLogs: (logs: readonly unknown[]) => void; }) => { - if (opts.watchers !== undefined) { - if (eventName === "LotCreated") { - opts.watchers.lotCreated = (logs) => onLogs(logs as unknown as readonly Log[]); - } else if (eventName === "LotClosed") { - opts.watchers.lotClosed = (logs) => onLogs(logs as unknown as readonly Log[]); - } + if (eventName === "OrderMatched" && opts.watchers) { + opts.watchers.orderMatched = onLogs as (logs: readonly MatchedLog[]) => void; + } + if (eventName === "PositionSettled" && opts.watchers) { + opts.watchers.positionSettled = onLogs as (logs: readonly SettledLog[]) => void; } return () => undefined; }, + simulateContract: async ({ args }: { args: readonly unknown[] }) => { + const result = opts.simulate?.(args) ?? { request: { ok: true as const } }; + if ("error" in result) throw result.error; + return result; + }, + waitForTransactionReceipt: async () => receipt, }, walletClient: { chain: null, - writeContract: async (req: readonly unknown[]) => { - calls.writes.push(req); - return opts.writeHash ?? ("0xdeadbeef" as `0x${string}`); - }, - // Used by unstick to send 0-value cancellation self-transfers. - // Returns a fake hash; tests that care about cancellations - // override this in the per-test chain object directly. - sendTransaction: async () => "0xcafe" as `0x${string}`, + writeContract: async () => writeHash, }, - calls, - } as unknown as Chain & { - calls: { writes: Array; simulates: Array }; - }; - return chain; + } as unknown as Chain; } -const FUTURES_ABI = parseAbi([ - "error PositionDeliveryNotStartedYet()", - "error PositionDeliveryExpired()", - "error PositionNotExists()", - "error OnlyValidatorOrPositionParticipant()", - "error UnknownProblem()", - "function settlePosition(bytes32 positionId)", -]); - -/** Build the same shape of revert viem hands `simulateContract` callers. */ -function makeRevert(errorName: string): BaseError { - const inner = new ContractFunctionRevertedError({ - abi: FUTURES_ABI, - data: undefined, - functionName: "settlePosition", - }); - (inner as unknown as { data: { errorName: string } }).data = { errorName }; - const outer = new BaseError("simulated revert"); - (outer as unknown as { cause: unknown }).cause = inner; - return outer; -} - -describe("DeliveryCoordinator: revert classification", () => { - it("recognises the maturity guard and benign already-settled as recoverable", () => { - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryNotStartedYet")), - "PositionDeliveryNotStartedYet", - ); - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("PositionNotExists")), - "PositionNotExists", - ); - }); - - it("treats oracle freshness reverts as recoverable so the sweep retries", () => { - assert.equal(__testing.decodeRecoverableRevert(makeRevert("OracleStale")), "OracleStale"); - assert.equal(__testing.decodeRecoverableRevert(makeRevert("InvalidOracle")), "InvalidOracle"); - }); - - it("no longer classifies retired validator/window reverts as recoverable", () => { - // settlePosition is permissionless with no upper time bound, so these - // closeDelivery-era reverts can never occur and are treated as unknown. - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("PositionDeliveryExpired")), - undefined, - ); - assert.equal( - __testing.decodeRecoverableRevert(makeRevert("OnlyValidatorOrPositionParticipant")), - undefined, - ); - }); - - it("does not classify unknown reverts as recoverable", () => { - assert.equal(__testing.decodeRecoverableRevert(makeRevert("UnknownProblem")), undefined); - assert.equal(__testing.decodeRecoverableRevert(new Error("boom")), undefined); +describe("delivery/coordinator: trackKey helpers", () => { + it("decodeRecoverableRevert recognises PositionNotExists", () => { + const err = new BaseError("x", { + cause: new ContractFunctionRevertedError({ + abi: [{ type: "error", name: "PositionNotExists", inputs: [] }], + data: "0x", + } as never), + }); + // viem wrapping varies — exercise the exported helper with a synthetic shape. + const synthetic = Object.assign(new BaseError("revert"), { + walk: (fn: (e: unknown) => unknown) => { + const inner = new ContractFunctionRevertedError({ + abi: [{ type: "error", name: "PositionNotExists", inputs: [] }], + data: "0x08c379a0", + } as never); + (inner as { data?: { errorName?: string } }).data = { errorName: "PositionNotExists" }; + return fn(inner) ? inner : null; + }, + }); + assert.equal(__testing.decodeRecoverableRevert(synthetic), "PositionNotExists"); + void err; }); }); -describe("DeliveryCoordinator: live event handling", () => { - it("indexes positions on LotCreated and removes them on LotClosed", async () => { +describe("delivery/coordinator: event indexing", () => { + it("indexes maker+taker on OrderMatched and drops on PositionSettled", async () => { const watchers: ChainStubOptions["watchers"] = {}; const chain = makeChain({ watchers }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.start(); - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); - assert.equal(coordinator.size(), 1); - assert.ok(coordinator.has(POSITION_A)); + watchers.orderMatched?.([ + matchedLog(USER_A, USER_B, DELIVERY_A, -1n, 1n), + ]); + assert.equal(coord.size(), 2); + assert.equal(coord.has(USER_A, DELIVERY_A), true); + assert.equal(coord.has(USER_B, DELIVERY_A), true); - watchers.lotClosed?.([lotClosedLog(POSITION_A)]); - assert.equal(coordinator.size(), 0); - coordinator.stop(); + watchers.positionSettled?.([settledLog(USER_A, DELIVERY_A)]); + assert.equal(coord.has(USER_A, DELIVERY_A), false); + assert.equal(coord.has(USER_B, DELIVERY_A), true); + + coord.stop(); }); - it("dedupes duplicate LotCreated for the same id (live + backfill overlap)", async () => { + it("drops a user when OrderMatched reports netQtyAfter=0", async () => { const watchers: ChainStubOptions["watchers"] = {}; const chain = makeChain({ watchers }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); - watchers.lotCreated?.([lotCreatedLog(POSITION_A, future)]); - assert.equal(coordinator.size(), 1); - coordinator.stop(); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.start(); + + watchers.orderMatched?.([matchedLog(USER_A, USER_B, DELIVERY_A, -1n, 1n)]); + watchers.orderMatched?.([matchedLog(USER_A, USER_B, DELIVERY_A, 0n, 0n)]); + assert.equal(coord.has(USER_A, DELIVERY_A), false); + assert.equal(coord.has(USER_B, DELIVERY_A), false); + coord.stop(); }); -}); -describe("DeliveryCoordinator: settle()", () => { - it("simulates and broadcasts settlePosition for the matured lot", async () => { - let simulatedArgs: readonly unknown[] | undefined; - const chain = makeChain({ - simulate: (args) => { - simulatedArgs = args; - return { request: { ok: true } }; - }, - writeHash: "0xfeed", - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - // Inject directly via the live watcher stub so we don't have to wait - // on a real timer. - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - - assert.deepEqual(simulatedArgs, [POSITION_A]); - assert.equal(chain.calls.writes.length, 1); - assert.equal(coordinator.has(POSITION_A), false, "settled position is dropped"); - coordinator.stop(); - }); - - it("dryRun skips the broadcast but still drops the position from the index", async () => { - const chain = makeChain({ - simulate: () => ({ request: { ok: true } }), - }); - const config = makeConfig(); - (config.keeper as { dryRun: boolean }).dryRun = true; - const coordinator = new DeliveryCoordinator(chain, config, silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - - await coordinator.settle(POSITION_A); - assert.equal(chain.calls.writes.length, 0, "no tx in dryRun"); - assert.equal(coordinator.has(POSITION_A), false); - coordinator.stop(); - }); - - it("drops positions on PositionNotExists (already settled by someone else)", async () => { - const chain = makeChain({ - simulate: () => ({ error: makeRevert("PositionNotExists") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - assert.equal(coordinator.has(POSITION_A), false); - coordinator.stop(); - }); - - it("keeps positions on PositionDeliveryNotStartedYet (sweep will retry)", async () => { - const chain = makeChain({ - simulate: () => ({ error: makeRevert("PositionDeliveryNotStartedYet") }), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - assert.equal(coordinator.has(POSITION_A), true, "still tracked — sweep retries later"); - coordinator.stop(); - }); - - it("logs unknown simulate reverts at error and skips them from the batch", async () => { - // New batching contract: an unknown simulate revert does NOT take down - // the keeper. Instead it's logged at error level (visible to ops) and - // the offending position is filtered out so the rest of the batch - // still settles. Crashing on one bad apple was the old per-id - // behaviour and proved fragile in production — a single position with - // weird state would unhandled-reject the setTimeout-fired settle and - // exit the process. - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ simulate: () => ({ error: makeRevert("UnknownProblem") }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); // must NOT throw - const errors = calls.filter( - (c) => c.level === "error" && /non-recoverable error/.test(c.msg), - ); - assert.equal(errors.length, 1, "operator-visible error log"); - assert.equal(chain.calls.writes.length, 0, "no broadcast — nothing in batch"); - assert.equal(coordinator.has(POSITION_A), true, "left for next sweep to surface again"); - coordinator.stop(); - }); - - it("coalesces concurrent settle() calls for the same positionId", async () => { - // Block the first simulate via a deferred so the second call observes - // the in-flight set before the first finishes. - let simulateCount = 0; - let resolveFirst: () => void = () => undefined; - const blocker = new Promise((r) => { - resolveFirst = r; - }); - const chain = makeChain({ - simulate: () => { - simulateCount++; - return { request: { ok: true } }; - }, - writeHash: "0xfeed", - }); - // Slow the first simulate by patching the publicClient method via a typed - // wrapper that still satisfies viem's overloaded signature. - const realSimulate = chain.publicClient.simulateContract.bind(chain.publicClient); - const slowed = async (...args: unknown[]) => { - const out = await (realSimulate as (...a: unknown[]) => Promise)(...args); - if (simulateCount === 1) await blocker; - return out; - }; - (chain.publicClient as unknown as { simulateContract: typeof slowed }).simulateContract = - slowed; - - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - - const first = coordinator.settle(POSITION_A); - const second = coordinator.settle(POSITION_A); - resolveFirst(); - await Promise.all([first, second]); - - assert.equal(simulateCount, 1, "second concurrent settle is a no-op"); - coordinator.stop(); + it("dedupes duplicate OrderMatched for the same user+expiry", async () => { + const watchers: ChainStubOptions["watchers"] = {}; + const chain = makeChain({ watchers }); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.start(); + + const log = matchedLog(USER_A, USER_B, DELIVERY_A, -1n, 1n); + watchers.orderMatched?.([log]); + watchers.orderMatched?.([log]); + assert.equal(coord.size(), 2); + coord.stop(); }); }); -describe("DeliveryCoordinator: settleBatch()", () => { - it("bundles N settlePosition calls into one Futures.multicall(bytes[]) tx", async () => { - // The whole point of batching: even with 3 candidates, we want exactly - // one writeContract call (one nonce) so a concurrent manual send or a - // stale pending tx can't cause `replacement transaction underpriced`. - let simulateCount = 0; - const writeArgs: unknown[] = []; +describe("delivery/coordinator: bootstrap + settle", () => { + it("bootstrapFromUsers indexes active aggregates", async () => { const chain = makeChain({ - simulate: () => { - simulateCount++; - return { request: { ok: true } }; + activeDatesByUser: { + [USER_A.toLowerCase()]: [DELIVERY_A, DELIVERY_B], }, - writeHash: "0xfeed", - }); - const recordingWrite = async (req: unknown) => { - writeArgs.push(req); - return "0xfeed" as `0x${string}`; - }; - (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = - recordingWrite; - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - for (const id of [POSITION_A, POSITION_B, POSITION_C]) { - coordinator["tracked"].set(id, { - positionId: id, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - } - await coordinator.settleBatch([POSITION_A, POSITION_B, POSITION_C]); - assert.equal(simulateCount, 3, "simulated each candidate to filter reverters"); - assert.equal(writeArgs.length, 1, "exactly one broadcast — one nonce, no race"); - const req = writeArgs[0] as { functionName: string; args: readonly [readonly `0x${string}`[]] }; - assert.equal(req.functionName, "multicall"); - assert.equal(req.args[0].length, 3, "three encoded settlePosition calls in the bundle"); - for (const id of [POSITION_A, POSITION_B, POSITION_C]) { - assert.equal(coordinator.has(id), false, `${id} dropped after multicall confirms`); - } - coordinator.stop(); - }); - - it("filters out per-id reverters before broadcast so one bad apple can't poison the batch", async () => { - // POSITION_B reverts in simulate (PositionNotExists); A and C are fine. - // The multicall must contain only A and C — including B would revert - // the entire bundle and leave A & C unsettled. Critical test. - let simulateCount = 0; - const writeArgs: unknown[] = []; - const chain = makeChain({ - simulate: (args: readonly unknown[]) => { - simulateCount++; - if (args[0] === POSITION_B) return { error: makeRevert("PositionNotExists") }; - return { request: { ok: true } }; + positionsByUserDate: { + [posKey(USER_A, DELIVERY_A)]: { netQuantity: 1n, netEntryValue: 50n }, + [posKey(USER_A, DELIVERY_B)]: { netQuantity: -2n, netEntryValue: -100n }, }, + // Keep sweep idle — timestamps far in the future. + blockTimestamp: 1n, }); - const recordingWrite = async (req: unknown) => { - writeArgs.push(req); - return "0xfeed" as `0x${string}`; - }; - (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = - recordingWrite; - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - for (const id of [POSITION_A, POSITION_B, POSITION_C]) { - coordinator["tracked"].set(id, { - positionId: id, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - } - await coordinator.settleBatch([POSITION_A, POSITION_B, POSITION_C]); - assert.equal(simulateCount, 3); - assert.equal(writeArgs.length, 1, "still one broadcast for the survivors"); - const req = writeArgs[0] as { args: readonly [readonly `0x${string}`[]] }; - assert.equal(req.args[0].length, 2, "B filtered out, A + C remain"); - assert.equal(coordinator.has(POSITION_A), false); - assert.equal(coordinator.has(POSITION_B), false, "PositionNotExists drops B from index"); - assert.equal(coordinator.has(POSITION_C), false); - coordinator.stop(); - }); - - it("treats `replacement transaction underpriced` as recoverable — does not throw", async () => { - // The original failure mode this whole batching change is designed - // to mitigate. Even when the underlying RPC bounces a replacement - // tx, the keeper must not crash — the next sweep tick will retry - // with a fresh nonce. - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); - const failingWrite = async () => { - throw new Error("replacement transaction underpriced"); - }; - (chain.walletClient as unknown as { writeContract: typeof failingWrite }).writeContract = - failingWrite; - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settleBatch([POSITION_A]); // must NOT throw - assert.equal(coordinator.has(POSITION_A), true, "left tracked for next sweep retry"); - const warns = calls.filter((c) => c.level === "warn" && /tx submission failed/.test(c.msg)); - assert.equal(warns.length, 1, "warned about transient submission failure"); - coordinator.stop(); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.bootstrapFromUsers([USER_A]); + assert.equal(coord.size(), 2); + assert.equal(coord.has(USER_A, DELIVERY_A), true); + assert.equal(coord.has(USER_A, DELIVERY_B), true); }); - it("respects DELIVERY_MAX_BATCH_SIZE by splitting large sweeps across multiple multicalls", async () => { - // 5 candidates, batch size 2 → 3 multicalls (2 + 2 + 1). - const writeArgs: unknown[] = []; - const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); - const recordingWrite = async (req: unknown) => { - writeArgs.push(req); - return "0xfeed" as `0x${string}`; - }; - (chain.walletClient as unknown as { writeContract: typeof recordingWrite }).writeContract = - recordingWrite; - const coordinator = new DeliveryCoordinator( - chain, - makeConfig({ maxBatchSize: 2 }), - silentLogger, - ); - await coordinator.start(); - // Real 32-byte hex — the production code calls `encodeFunctionData` - // which strictly validates `bytes32` width, so test fixtures must - // match that width or encode throws before any tx is built. - const ids: `0x${string}`[] = [ - `0xa${"1".repeat(63)}`, - `0xa${"2".repeat(63)}`, - `0xa${"3".repeat(63)}`, - `0xa${"4".repeat(63)}`, - `0xa${"5".repeat(63)}`, - ] as `0x${string}`[]; - // Just past delivery so sweep picks them up but the settlement window - // (default 7d) hasn't expired — `deliveryAt: 0n` would land outside - // the window vs. the wall-clock-based stub `getBlock()` timestamp and - // get silently dropped before any broadcast. - const recentlyPast = BigInt(Math.floor(Date.now() / 1000)) - 60n; - for (const id of ids) { - coordinator["tracked"].set(id, { - positionId: id, - deliveryAt: recentlyPast, - seller: SELLER, - buyer: BUYER, - }); - } - await coordinator.sweep(); - assert.equal(writeArgs.length, 3, "5 ids @ batch=2 → ceil(5/2) multicalls"); - const sizes = writeArgs.map((r) => (r as { args: [`0x${string}`[]] }).args[0].length); - assert.deepEqual(sizes.sort(), [1, 2, 2]); - coordinator.stop(); - }); -}); - -describe("DeliveryCoordinator: backfill", () => { - it("seeds the index from historical LotCreated and respects subsequent LotClosed", async () => { - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; + it("indexUserPositions swallows getActiveDeliveryDates RPC errors", async () => { const chain = makeChain({ - blockNumber: 1000n, - simulate: () => ({ request: { ok: true } }), - writeHash: "0xfeed", - history: { - LotCreated: [ - lotCreatedLog(POSITION_A, future), - lotCreatedLog(POSITION_B, future + 86_400n), - lotCreatedLog(POSITION_C, future + 2n * 86_400n), - ], - // C was already closed historically — backfill should not leave it scheduled. - LotClosed: [lotClosedLog(POSITION_C)], - }, + readContractError: (fn) => + fn === "getActiveDeliveryDates" ? new Error("rpc down") : undefined, }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - await coordinator.backfill(0n, 10_000n); - - assert.equal(coordinator.size(), 2); - assert.ok(coordinator.has(POSITION_A)); - assert.ok(coordinator.has(POSITION_B)); - assert.equal(coordinator.has(POSITION_C), false); - coordinator.stop(); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.indexUserPositions(USER_A); // must not throw + assert.equal(coord.size(), 0); }); - it("settles past-due positions found during backfill on the immediate sweep", async () => { - const past = BigInt(Math.floor(Date.now() / 1000)) - 60n; // 60s ago - let simulateCount = 0; + it("settleBatch simulates settlePosition(user, deliveryAt) and drops on success", async () => { + const simulated: unknown[][] = []; const chain = makeChain({ - blockNumber: 1000n, - simulate: () => { - simulateCount++; + // Far-future timestamp so bootstrap's trailing sweep is a no-op. + blockTimestamp: 1n, + simulate: (args) => { + simulated.push([...args]); return { request: { ok: true } }; }, - writeHash: "0xfeed", - history: { - LotCreated: [lotCreatedLog(POSITION_A, past)], + activeDatesByUser: { [USER_A.toLowerCase()]: [DELIVERY_A] }, + positionsByUserDate: { + [posKey(USER_A, DELIVERY_A)]: { netQuantity: 1n, netEntryValue: 50n }, }, }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - await coordinator.backfill(0n, 10_000n); - - assert.equal(simulateCount, 1, "past-due position settled on backfill sweep"); - assert.equal(chain.calls.writes.length, 1); - assert.equal(coordinator.has(POSITION_A), false); - coordinator.stop(); + const coord = new DeliveryCoordinator(chain, makeConfig({ settleDelayMs: 0 }), silentLogger); + await coord.bootstrapFromUsers([USER_A]); + assert.equal(coord.has(USER_A, DELIVERY_A), true); + await coord.settle(USER_A, DELIVERY_A); + assert.equal(simulated.length, 1); + assert.equal( + (simulated[0]?.[0] as string).toLowerCase(), + USER_A.toLowerCase(), + ); + assert.equal(simulated[0]?.[1], DELIVERY_A); + assert.equal(coord.has(USER_A, DELIVERY_A), false); }); - it("still settles long-ago matured positions (settlePosition has no expiry window)", async () => { - // Pre-cash-settlement this would be pruned as "window expired". Now any - // matured position stays settleable forever, so backfill settles it. - const longAgo = BigInt(Math.floor(Date.now() / 1000)) - 8n * 86_400n; - let simulateCount = 0; + it("backfill replays OrderMatched then PositionSettled", async () => { const chain = makeChain({ - blockNumber: 1000n, - simulate: () => { - simulateCount++; - return { request: { ok: true } }; - }, - writeHash: "0xfeed", + blockNumber: 50n, history: { - LotCreated: [lotCreatedLog(POSITION_A, longAgo)], + OrderMatched: [matchedLog(USER_A, USER_B, DELIVERY_A, -1n, 1n)], + PositionSettled: [settledLog(USER_A, DELIVERY_A)], }, + blockTimestamp: 1n, }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - await coordinator.backfill(0n, 10_000n); - - assert.equal(simulateCount, 1, "matured position is settled, not abandoned"); - assert.equal(chain.calls.writes.length, 1); - assert.equal(coordinator.has(POSITION_A), false, "settled position dropped"); - coordinator.stop(); - }); - - it("rejects non-positive chunkSize", async () => { - const chain = makeChain({ blockNumber: 1000n }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - await assert.rejects(() => coordinator.backfill(0n, 0n)); - coordinator.stop(); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.backfill(1n, 100n); + assert.equal(coord.has(USER_A, DELIVERY_A), false); + assert.equal(coord.has(USER_B, DELIVERY_A), true); }); }); -describe("DeliveryCoordinator: recoverable-revert log severity", () => { - // settlePosition is permissionless with no expiry window, so the only - // terminal revert is the benign "already settled" case (logged at info). - // Everything else is transient and stays at debug so the sweep retries - // quietly. - - it("logs at info (not error) when someone else already settled the position", async () => { - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ simulate: () => ({ error: makeRevert("PositionNotExists") }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - const infos = calls.filter((c) => c.level === "info" && c.obj.revert === "PositionNotExists"); - const loud = calls.filter((c) => c.level === "warn" || c.level === "error"); - assert.equal(infos.length, 1, "benign termination → info"); - assert.equal(loud.length, 0, "not an operator-actionable failure → no warn/error"); - coordinator.stop(); - }); - - it("keeps transient reverts (NotStartedYet, OracleStale, InvalidOracle) at debug", async () => { - for (const revert of ["PositionDeliveryNotStartedYet", "OracleStale", "InvalidOracle"]) { - const { logger, calls } = makeRecordingLogger(); - const chain = makeChain({ simulate: () => ({ error: makeRevert(revert) }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), logger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: 0n, - seller: SELLER, - buyer: BUYER, - }); - await coordinator.settle(POSITION_A); - const debugs = calls.filter((c) => c.level === "debug" && c.obj.revert === revert); - const loud = calls.filter((c) => c.level === "warn" || c.level === "error"); - assert.ok(debugs.length >= 1, `${revert} should debug-log`); - assert.equal(loud.length, 0, `${revert} is transient — must not warn/error`); - coordinator.stop(); - } - }); -}); - -describe("DeliveryCoordinator: view-based discovery", () => { - it("bootstrapFromUsers indexes still-alive positions and skips closed ones", async () => { - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - const userA = "0x000000000000000000000000000000000000A11C" as Address; - const userB = "0x000000000000000000000000000000000000b0b1" as Address; - const chain = makeChain({ - blockNumber: 1000n, - simulate: () => ({ request: { ok: true } }), - writeHash: "0xfeed", - positionIdsByUser: new Map([ - [userA, [POSITION_A, POSITION_C]], - [userB, [POSITION_B]], - ]), - positionsById: new Map([ - [POSITION_A, { seller: SELLER, buyer: userA, deliveryAt: future }], - [POSITION_B, { seller: SELLER, buyer: userB, deliveryAt: future + 86_400n }], - // POSITION_C: not in the map → zero-struct → seller==0 → already closed → skipped. - ]), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - await coordinator.bootstrapFromUsers([userA, userB]); - - assert.equal(coordinator.size(), 2, "two live positions indexed, closed one skipped"); - assert.ok(coordinator.has(POSITION_A)); - assert.ok(coordinator.has(POSITION_B)); - assert.equal(coordinator.has(POSITION_C), false); - coordinator.stop(); - }); - - it("bootstrapFromUsers settles past-due positions on the trailing sweep", async () => { - const past = BigInt(Math.floor(Date.now() / 1000)) - 60n; - const user = "0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4" as Address; - let simulateCount = 0; - const chain = makeChain({ - blockNumber: 1000n, - simulate: () => { - simulateCount++; - return { request: { ok: true } }; - }, - writeHash: "0xfeed", - positionIdsByUser: new Map([[user, [POSITION_A]]]), - positionsById: new Map([[POSITION_A, { seller: SELLER, buyer: user, deliveryAt: past }]]), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - await coordinator.bootstrapFromUsers([user]); - - assert.equal(simulateCount, 1, "past-due position settled on bootstrap sweep"); - assert.equal(chain.calls.writes.length, 1); - assert.equal(coordinator.has(POSITION_A), false); - coordinator.stop(); - }); - - it("bootstrapFromUsers is a no-op for an empty user list", async () => { - const chain = makeChain({ blockNumber: 1000n }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - await coordinator.bootstrapFromUsers([]); - - assert.equal(coordinator.size(), 0); - coordinator.stop(); - }); - - it("bootstrapFromUsers does not re-index already-tracked positions", async () => { - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - const user = "0x000000000000000000000000000000000000A11C" as Address; - const chain = makeChain({ - blockNumber: 1000n, - simulate: () => ({ request: { ok: true } }), - writeHash: "0xfeed", - positionIdsByUser: new Map([[user, [POSITION_A]]]), - positionsById: new Map([[POSITION_A, { seller: SELLER, buyer: user, deliveryAt: future }]]), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator["tracked"].set(POSITION_A, { - positionId: POSITION_A, - deliveryAt: future, - seller: SELLER, - buyer: user, - }); - - await coordinator.bootstrapFromUsers([user]); - - assert.equal(coordinator.size(), 1, "no duplicate entry"); - coordinator.stop(); - }); - - it("indexUserPositions discovers a single user's positions", async () => { - const future = BigInt(Math.floor(Date.now() / 1000)) + 365n * 86_400n; - const user = "0x000000000000000000000000000000000000A11C" as Address; - const chain = makeChain({ - blockNumber: 1000n, - positionIdsByUser: new Map([[user, [POSITION_A, POSITION_B]]]), - positionsById: new Map([ - [POSITION_A, { seller: SELLER, buyer: user, deliveryAt: future }], - [POSITION_B, { seller: SELLER, buyer: user, deliveryAt: future + 86_400n }], - ]), - }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - - await coordinator.indexUserPositions(user); - - assert.equal(coordinator.size(), 2); - assert.ok(coordinator.has(POSITION_A)); - assert.ok(coordinator.has(POSITION_B)); - coordinator.stop(); - }); - - it("indexUserPositions swallows getPositionIds RPC errors instead of throwing", async () => { - const user = "0x000000000000000000000000000000000000A11C" as Address; - const chain = makeChain({ blockNumber: 1000n }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - // Force getPositionIds to blow up *after* startup (which uses readContract - // for `validatorAddress`). The listener path must never throw — - // bubbling out would crash the tracker's onAdded fan-out. - chain.publicClient.readContract = (async () => { - throw new Error("rpc down"); - }) as unknown as typeof chain.publicClient.readContract; - - await coordinator.indexUserPositions(user); - assert.equal(coordinator.size(), 0, "discovery error degrades silently"); - coordinator.stop(); - }); -}); - -describe("DeliveryCoordinator: lifecycle", () => { - it("start() is idempotent", async () => { - const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - await coordinator.start(); - coordinator.stop(); - }); - - it("stop() is idempotent and clears all timers", async () => { - const chain = makeChain({ simulate: () => ({ request: { ok: true } }) }); - const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); - await coordinator.start(); - coordinator.stop(); - coordinator.stop(); +describe("delivery/coordinator: isTransientTxError", () => { + it("matches common mempool / nonce failures", () => { + assert.equal( + __testing.isTransientTxError(new Error("replacement transaction underpriced")), + true, + ); + assert.equal(__testing.isTransientTxError(new Error("nonce too low")), true); + assert.equal(__testing.isTransientTxError(new Error("execution reverted")), false); }); }); diff --git a/keeper/tests/discovery/tracker.test.ts b/keeper/tests/discovery/tracker.test.ts index c5c0784..0a75f06 100644 --- a/keeper/tests/discovery/tracker.test.ts +++ b/keeper/tests/discovery/tracker.test.ts @@ -180,8 +180,8 @@ describe("ParticipantTracker: backfill", () => { [scriptKey(config.futures.address, "OrderCreated")]: [ { args: { participant: userAt(6) } }, ], - [scriptKey(config.futures.address, "LotCreated")]: [ - { args: { seller: userAt(7), buyer: userAt(8) } }, + [scriptKey(config.futures.address, "OrderMatched")]: [ + { args: { maker: userAt(7), taker: userAt(8) } }, ], }, }), diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index e9c9c2e..cb57f01 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -153,8 +153,8 @@ const PERPS_MAKER_FEE_BPS = 0n; const FUTURES_TAKER_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_MARGIN_PCT = 20; -/** Spacing, in days, between successive expiries (renamed from delivery interval). */ -const FUTURES_EXPIRATION_INTERVAL_DAYS = 7; +/** Spacing, in days, between successive expiries — must match Futures.EXPIRATION_INTERVAL_DAYS (30). */ +const FUTURES_EXPIRATION_INTERVAL_DAYS = 30; const FUTURES_FUTURE_DELIVERY_DATES_COUNT = 10; const INSURANCE_FUND = parseUnits("100000", TOKEN_DECIMALS); const INITIAL_USER_BALANCE = parseUnits("10000", TOKEN_DECIMALS); diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 2719c58..2e7e01d 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -100,41 +100,38 @@ export async function readPerpsOrderIds( })) as readonly Hex[]; } -export async function readFuturesPositionIds( +/** Active delivery dates for a user's unilateral futures aggregates. */ +export async function readFuturesActiveDates( stack: DeployedStack, user: Address, -): Promise { +): Promise { return (await stack.publicClient.readContract({ address: stack.addresses.futures, abi: stack.abis.futures, - functionName: "getPositionIds", + functionName: "getActiveDeliveryDates", args: [user], - })) as readonly Hex[]; + })) as readonly bigint[]; +} + +/** @deprecated alias — returns active delivery dates (no longer lot ids). */ +export async function readFuturesPositionIds( + stack: DeployedStack, + user: Address, +): Promise { + const dates = await readFuturesActiveDates(stack, user); + // Encode deliveryAt as bytes32 for callers that still treat them as Hex ids. + return dates.map((d) => `0x${d.toString(16).padStart(64, "0")}` as Hex); } /** - * Reads the `deliveryAt` (expiration timestamp) of each supplied futures lot - * id via `getPositionById`. Must be called *before* the lots are liquidated — - * the contract deletes a position from storage on close, so the mapping has to - * be snapshotted while every lot is still alive. Used by the multi-expiry - * balancing test to attribute each closed lot back to its book. + * Decodes Hex-encoded deliveryAt values (from `readFuturesPositionIds`) back + * to bigint expiries. Kept for multi-expiry balancing tests. */ export async function readFuturesLotExpiries( - stack: DeployedStack, + _stack: DeployedStack, ids: readonly Hex[], ): Promise> { - const entries = await Promise.all( - ids.map(async (id) => { - const pos = (await stack.publicClient.readContract({ - address: stack.addresses.futures, - abi: stack.abis.futures, - functionName: "getPositionById", - args: [id], - })) as { deliveryAt: bigint }; - return [id, pos.deliveryAt] as const; - }), - ); - return new Map(entries); + return new Map(ids.map((id) => [id, BigInt(id)] as const)); } export async function readFuturesOrderIds( @@ -144,7 +141,7 @@ export async function readFuturesOrderIds( return (await stack.publicClient.readContract({ address: stack.addresses.futures, abi: stack.abis.futures, - functionName: "getOrderIds", + functionName: "getUserOrders", args: [user], })) as readonly Hex[]; } @@ -248,11 +245,9 @@ export async function expectReducedToImBuffer( } /** - * Every block a `Futures.LotLiquidated` event was emitted at for `participant`. - * Unlike the `earliestEventBlock` readers this keeps the full list so tests can - * assert a batched liquidation collapses all lots into a single block (the - * anti-churn regression guard) — reusing the `Set` pattern from the - * delivery-coordinator multicall test. + * Every block a `Futures.PositionLiquidated` event was emitted for `user`. + * Kept as a list so tests can assert a batched liquidation collapses into a + * single block (anti-churn). */ export async function readFuturesLotLiquidatedBlocks( stack: DeployedStack, @@ -261,8 +256,8 @@ export async function readFuturesLotLiquidatedBlocks( const logs = await stack.publicClient.getContractEvents({ address: stack.addresses.futures, abi: stack.abis.futures, - eventName: "LotLiquidated", - args: { participant: user }, + eventName: "PositionLiquidated", + args: { user }, fromBlock: 0n, }); const blocks: bigint[] = []; @@ -272,6 +267,41 @@ export async function readFuturesLotLiquidatedBlocks( return blocks; } +/** Absolute contracts closed across all PositionLiquidated events for `user`. */ +export async function readFuturesClosedQuantity( + stack: DeployedStack, + user: Address, +): Promise { + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses.futures, + abi: stack.abis.futures, + eventName: "PositionLiquidated", + args: { user }, + fromBlock: 0n, + }); + let sum = 0n; + for (const log of logs) { + const q = (log.args as { closedQuantity?: bigint }).closedQuantity; + if (q === undefined) continue; + sum += q < 0n ? -q : q; + } + return sum; +} + +export async function readFuturesNetQuantity( + stack: DeployedStack, + user: Address, + deliveryAt: bigint, +): Promise { + const pos = (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getUserPosition", + args: [user, deliveryAt], + })) as { netQuantity: bigint }; + return pos.netQuantity; +} + /** * Asserts every supplied block number is identical — i.e. the events all rode * a single transaction/block. `label` names the batched call for diagnostics. @@ -311,35 +341,35 @@ export async function expectNoOpenOrders( // Tests then compare block numbers across helpers to assert the planner's // invariants (orders-leg before position-leg, worst-leg first, etc). // -// The perps event indexes `user`; the futures event indexes `participant`. -// viem doesn't auto-translate, so each helper passes the right kwarg. +// Both venues index liquidations on `user` in 3.0. export const readPerpsPositionLiquidationBlock = (s: DeployedStack, u: Address) => earliestEventBlock(s, "perps", "PositionLiquidated", { user: u }); export const readFuturesPositionLiquidationBlock = (s: DeployedStack, u: Address) => - earliestEventBlock(s, "futures", "LotLiquidated", { participant: u }); + earliestEventBlock(s, "futures", "PositionLiquidated", { user: u }); export const readPerpsOrderLiquidationBlock = (s: DeployedStack, u: Address) => earliestEventBlock(s, "perps", "OrderLiquidated", { user: u }); export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) => - earliestEventBlock(s, "futures", "OrderLiquidated", { participant: u }); + earliestEventBlock(s, "futures", "OrderLiquidated", { user: u }); /** - * Earliest block at which `Futures.LotClosed(lotId)` was - * emitted. Used by the delivery-coordinator e2e tests to confirm the keeper - * actually settled a specific position id via `settlePosition`. + * Earliest block at which `Futures.PositionSettled(user, deliveryAt)` was + * emitted. `deliveryAtHex` is the bytes32 encoding from `readFuturesPositionIds`. */ export async function readLotClosedBlock( stack: DeployedStack, - lotId: Hex, + user: Address, + deliveryAtHex: Hex, ): Promise { + const deliveryAt = BigInt(deliveryAtHex); const logs = await stack.publicClient.getContractEvents({ address: stack.addresses.futures, abi: stack.abis.futures, - eventName: "LotClosed", - args: { lotId }, + eventName: "PositionSettled", + args: { user, deliveryAt }, fromBlock: 0n, }); let earliest: bigint | null = null; @@ -353,7 +383,7 @@ export async function readLotClosedBlock( async function earliestEventBlock( stack: DeployedStack, venue: "perps" | "futures", - eventName: "PositionLiquidated" | "LotLiquidated" | "OrderLiquidated", + eventName: "PositionLiquidated" | "OrderLiquidated", args: Record, ): Promise { const logs = await stack.publicClient.getContractEvents({ diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 692ff35..e645b10 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -50,7 +50,8 @@ import { expectHealthy, expectReducedToImBuffer, readFuturesLotLiquidatedBlocks, - readFuturesLotExpiries, + readFuturesNetQuantity, + readFuturesClosedQuantity, assertSingleBlock, isCriticalAlert, waitFor, @@ -379,51 +380,44 @@ describe("Futures liquidation", () => { describe("Liquidate down to the IM buffer", () => { it( - "futures: one batched sweep closes a strict subset of lots into the [MM, IM] band", + "futures: one batched sweep closes a strict subset of qty into the [MM, IM] band", { timeout: 60_000 }, async () => { - // Precondition: alice holds 12 long futures lots; a moderate crash - // ($40 → $30 mark) breaks MM but a subset close restores the IM buffer. - // Contract under test (the screenshot bug fix): the planner must NOT - // fan out into one-lot-per-tx churn. Instead a single - // `liquidatePositions(user, ids[])` closes the worst-first subset in - // ONE block, leaves ≥1 lot open, and lands `MM <= balance <= IM`. + // Precondition: alice holds one aggregate long of 12 contracts; a moderate + // crash ($40 → $30 mark) breaks MM but a partial closeQty restores the IM + // buffer. A single `liquidatePositions(user, deliveryAts[], closeQtys[])` + // must land `MM <= balance <= IM` without full-closing the aggregate. const ctx = await loadFixture(futuresPartialCrashFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); const alice = ctx.accounts.alice.account.address; - const lotsBefore = await readFuturesPositionIds(ctx, alice); + const deliveryAt = ctx.config.futuresFirstDeliveryDate; + const datesBefore = await readFuturesPositionIds(ctx, alice); + assert.equal(datesBefore.length, 1, "precondition: one active expiry"); assert.equal( - lotsBefore.length, - ctx.aliceFuturesQty, - "precondition: alice should hold one lot per matched contract", + await readFuturesNetQuantity(ctx, alice, deliveryAt), + BigInt(ctx.aliceFuturesQty), + "precondition: aggregate net qty equals matched contracts", ); await ctx.makeLiquidatable(); await runOneSweep(keeper, alice); - // Landed in the buffer band — healthy but not over-liquidated. await expectReducedToImBuffer(ctx, alice); - // A strict subset closed: at least one lot remains open. - const lotsAfter = await readFuturesPositionIds(ctx, alice); - assert.ok( - lotsAfter.length > 0, - `expected a strict subset closed (>=1 lot open), got ${lotsAfter.length} remaining`, - ); + const netAfter = await readFuturesNetQuantity(ctx, alice, deliveryAt); + assert.ok(netAfter > 0n, `expected partial close (qty remaining), got ${netAfter}`); assert.ok( - lotsAfter.length < lotsBefore.length, - `expected some lots closed, before=${lotsBefore.length} after=${lotsAfter.length}`, + netAfter < BigInt(ctx.aliceFuturesQty), + `expected some contracts closed, before=${ctx.aliceFuturesQty} after=${netAfter}`, ); - // Anti-churn regression guard: every closed lot rides a SINGLE block. + const closed = await readFuturesClosedQuantity(ctx, alice); + assert.equal(closed, BigInt(ctx.aliceFuturesQty) - netAfter); + const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); - assert.equal( - liqBlocks.length, - lotsBefore.length - lotsAfter.length, - "expected one LotLiquidated event per closed lot", - ); + assert.ok(liqBlocks.length >= 1, "expected PositionLiquidated"); assertSingleBlock(liqBlocks, "futures liquidatePositions batch"); }, ); @@ -432,80 +426,55 @@ describe("Liquidate down to the IM buffer", () => { "futures: one batched sweep balances the subset close across two expirations", { timeout: 60_000 }, async () => { - // Precondition: alice holds 6 long futures lots on EACH of two delivery - // dates (12 total). The moderate crash ($40 → $30 mark) breaks MM; because - // the duration-free risk model weights every lot by the same per-day value - // (±1 delta each) regardless of expiry, the aggregate margin equals the - // single-expiry 12-lot case, so a worst-first subset restores the IM buffer. - // - // Contract under test (the balancing feature): the ONE - // `liquidatePositions(user, ids[])` call must draw its closed lots from - // BOTH books — not empty the first expiry before touching the second. - // We snapshot each lot's `deliveryAt` *before* the close (positions are - // deleted on liquidation), diff the surviving ids to find what closed, - // and assert the per-expiry counts are balanced (differ by ≤ 1) with at - // least one lot closed on each date. + // Precondition: alice holds 6-contract aggregates on EACH of two delivery + // dates. Moderate crash breaks MM; balanced unit closes restore the IM + // buffer without draining one expiry first. const ctx = await loadFixture(futuresMultiExpiryPartialCrashFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); const alice = ctx.accounts.alice.account.address; - const lotsBefore = await readFuturesPositionIds(ctx, alice); + const datesBefore = await readFuturesPositionIds(ctx, alice); assert.equal( - lotsBefore.length, - ctx.perExpiryQty * ctx.deliveryDates.length, - "precondition: alice holds perExpiryQty lots per delivery date", + datesBefore.length, + ctx.deliveryDates.length, + "precondition: one aggregate per delivery date", ); - - // Snapshot id → expiry while every lot is still alive on-chain. - const expiryById = await readFuturesLotExpiries(ctx, lotsBefore); const [firstDelivery, secondDelivery] = ctx.deliveryDates; + assert.ok(firstDelivery !== undefined && secondDelivery !== undefined); + assert.equal( + await readFuturesNetQuantity(ctx, alice, firstDelivery), + BigInt(ctx.perExpiryQty), + ); + assert.equal( + await readFuturesNetQuantity(ctx, alice, secondDelivery), + BigInt(ctx.perExpiryQty), + ); await ctx.makeLiquidatable(); await runOneSweep(keeper, alice); - // Landed in the buffer band — healthy but not over-liquidated. await expectReducedToImBuffer(ctx, alice); - const lotsAfter = await readFuturesPositionIds(ctx, alice); - const survivors = new Set(lotsAfter.map((id) => id.toLowerCase())); - const closed = lotsBefore.filter((id) => !survivors.has(id.toLowerCase())); + const afterFirst = await readFuturesNetQuantity(ctx, alice, firstDelivery); + const afterSecond = await readFuturesNetQuantity(ctx, alice, secondDelivery); + const remFirst = afterFirst < 0n ? -afterFirst : afterFirst; + const remSecond = afterSecond < 0n ? -afterSecond : afterSecond; + const closedA = BigInt(ctx.perExpiryQty) - remFirst; + const closedB = BigInt(ctx.perExpiryQty) - remSecond; assert.ok( - lotsAfter.length > 0 && lotsAfter.length < lotsBefore.length, - `expected a strict subset closed, before=${lotsBefore.length} after=${lotsAfter.length}`, + closedA >= 1n && closedB >= 1n, + `expected the close to span BOTH expirations, got first=${closedA} second=${closedB}`, ); - - // Attribute every closed lot back to its book. - let closedFirst = 0; - let closedSecond = 0; - for (const id of closed) { - const expiry = expiryById.get(id); - assert.ok(expiry !== undefined, `missing pre-close expiry for lot ${id}`); - if (expiry === firstDelivery) closedFirst += 1; - else if (expiry === secondDelivery) closedSecond += 1; - else assert.fail(`lot ${id} has an unexpected expiry ${expiry}`); - } - - // The balancing invariant: both books contributed, and the split is even - // (the round-robin worst-first selection differs by at most one lot). - assert.ok( - closedFirst >= 1 && closedSecond >= 1, - `expected the close to span BOTH expirations, got first=${closedFirst} second=${closedSecond}`, - ); - const skew = closedFirst > closedSecond ? closedFirst - closedSecond : closedSecond - closedFirst; + const skew = closedA > closedB ? closedA - closedB : closedB - closedA; assert.ok( - skew <= 1, - `expected a balanced split across expirations (skew <= 1), got first=${closedFirst} second=${closedSecond}`, + skew <= 1n, + `expected a balanced split across expirations (skew <= 1), got first=${closedA} second=${closedB}`, ); - // Anti-churn guard: the whole balanced subset rides a single block. const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); - assert.equal( - liqBlocks.length, - closed.length, - "expected one LotLiquidated event per closed lot", - ); + assert.ok(liqBlocks.length >= 1, "expected PositionLiquidated"); assertSingleBlock(liqBlocks, "futures multi-expiry liquidatePositions batch"); }, ); @@ -565,10 +534,10 @@ describe("Liquidate down to the IM buffer", () => { const perpsBefore = await readPerpsPosition(ctx, alice); assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 40 perps"); const futuresBefore = await readFuturesPositionIds(ctx, alice); + assert.equal(futuresBefore.length, 1, "precondition: one futures aggregate"); assert.equal( - futuresBefore.length, - ctx.aliceFuturesQty, - "precondition: alice holds the futures lot(s)", + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + BigInt(ctx.aliceFuturesQty), ); await ctx.makeLiquidatable(); @@ -592,6 +561,11 @@ describe("Liquidate down to the IM buffer", () => { futuresBefore.length, `expected the futures leg untouched, before=${futuresBefore.length} after=${futuresAfter.length}`, ); + assert.equal( + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + BigInt(ctx.aliceFuturesQty), + "futures net qty unchanged", + ); }, ); @@ -620,10 +594,11 @@ describe("Liquidate down to the IM buffer", () => { const perpsBefore = await readPerpsPosition(ctx, alice); assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 11 perps"); const futuresBefore = await readFuturesPositionIds(ctx, alice); + assert.equal(futuresBefore.length, 1, "precondition: one futures aggregate"); assert.equal( - futuresBefore.length, - ctx.aliceFuturesQty, - "precondition: alice holds 12 futures lots", + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + BigInt(ctx.aliceFuturesQty), + "precondition: alice holds 12 futures contracts", ); await ctx.makeLiquidatable(); @@ -645,7 +620,7 @@ describe("Liquidate down to the IM buffer", () => { const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event (perps leg swept)"); - assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event (futures leg swept)"); + assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event (futures leg swept)"); // Both legs reduced; the account is not fully wiped (partial regime). const perpsAfter = await readPerpsPosition(ctx, alice); @@ -737,7 +712,7 @@ describe("Cross-venue coordination", () => { const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); assert.ok( perpsBlock < futuresBlock, `expected perps liquidated before futures, got perps=${perpsBlock} futures=${futuresBlock}`, @@ -768,7 +743,7 @@ describe("Cross-venue coordination", () => { const perpsBlock = await readPerpsPositionLiquidationBlock(ctx, alice); const futuresBlock = await readFuturesPositionLiquidationBlock(ctx, alice); assert.ok(perpsBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresBlock !== null, "expected a futures LotLiquidated event"); + assert.ok(futuresBlock !== null, "expected a futures PositionLiquidated event"); assert.ok( futuresBlock < perpsBlock, `expected futures liquidated before perps, got perps=${perpsBlock} futures=${futuresBlock}`, @@ -825,7 +800,7 @@ describe("Cross-venue coordination", () => { assert.ok(perpsOrderBlock !== null, "expected a perps OrderLiquidated event"); assert.ok(futuresOrderBlock !== null, "expected a futures OrderLiquidated event"); assert.ok(perpsPositionBlock !== null, "expected a perps PositionLiquidated event"); - assert.ok(futuresPositionBlock !== null, "expected a futures LotLiquidated event"); + assert.ok(futuresPositionBlock !== null, "expected a futures PositionLiquidated event"); const latestOrderBlock = max(perpsOrderBlock, futuresOrderBlock); const earliestPositionBlock = min(perpsPositionBlock, futuresPositionBlock); @@ -962,7 +937,7 @@ describe("DeliveryCoordinator (live RPC)", () => { // // We then fast-forward the chain past `deliveryAt` and trigger one // sweep. `settlePosition` cash-settles the full position notional at the - // current market price and emits `LotClosed(SETTLED)`. + // current market price and emits `PositionSettled`. const ctx = await loadFixture(futuresLongCrashFixture, testClient); keeper = buildKeeper(ctx, { liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator (parity; not required) @@ -973,17 +948,18 @@ describe("DeliveryCoordinator (live RPC)", () => { const alice = ctx.accounts.alice.account.address; const positionsBefore = await readFuturesPositionIds(ctx, alice); - // The fixture creates one position per matched contract — Alice's - // 12-contract long becomes 12 separate position entries sharing one - // `deliveryAt`. Settling them all is the realistic case (one signer - // serializing many positions due at the same timestamp). - assert.equal(positionsBefore.length, ctx.aliceFuturesQty); + // 3.0: one unilateral aggregate per expiry (12 contracts → 1 active date). + assert.equal(positionsBefore.length, 1); + assert.equal( + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + BigInt(ctx.aliceFuturesQty), + ); // Seed the delivery index from history — the positions were created // before the keeper booted, so the live watcher hasn't seen them. await keeper.delivery.backfill(0n, 10_000n); for (const id of positionsBefore) { - assert.ok(keeper.delivery.has(id), `backfill should index position ${id}`); + assert.ok(keeper.delivery.has(alice, BigInt(id)), `backfill should index position ${id}`); } // Fast-forward past `deliveryAt`. `settlePosition` requires @@ -1002,40 +978,33 @@ describe("DeliveryCoordinator (live RPC)", () => { await keeper.delivery.sweep(); // End state: every position is gone from chain storage, each emitted - // a `LotClosed` event from the keeper's signer, and the + // a `PositionSettled` event from the keeper's signer, and the // index dropped all of them. await expectFuturesClosed(ctx, alice); // The index drop happens after the settling tx confirms. The // coordinator also runs a background safety-net sweep every // `sweepIntervalMs`; when it wins the race against this manual - // `sweep()` the on-chain `LotClosed` can be observable a tick before + // `sweep()` the on-chain `PositionSettled` can be observable a tick before // the in-memory index is pruned. Poll for the drop rather than // asserting it synchronously to avoid that race. const delivery = keeper.delivery; assert.ok(delivery); await waitFor( - () => positionsBefore.every((id) => !delivery.has(id)), + () => positionsBefore.every((id) => !delivery.has(alice, BigInt(id))), 10_000, ); const settledBlocks: bigint[] = []; for (const id of positionsBefore) { - const settledBlock = await readLotClosedBlock(ctx, id); + const settledBlock = await readLotClosedBlock(ctx, alice, id); assert.ok( settledBlock !== null, - `expected a LotClosed event for position ${id}`, + `expected a PositionSettled event for ${alice} @ ${id}`, ); settledBlocks.push(settledBlock); } - // Batching invariant: all 12 settlements ride a single - // `Futures.multicall(bytes[])` transaction, so every - // `LotClosed` event lands in the same block. Without - // batching they would have been N separate txs across N blocks - // (plus a `replacement transaction underpriced` race in production - // when two of them collided on the same nonce). This assertion - // locks in the multicall path — if someone reverts the coordinator - // to per-id sends, the blocks fan out and this fails. + // Single aggregate → one settle; still assert one block (multicall path). const uniqueBlocks = new Set(settledBlocks.map((b) => b.toString())); assert.equal( uniqueBlocks.size, @@ -1061,7 +1030,7 @@ describe("DeliveryCoordinator (live RPC)", () => { const ctx = await loadFixture(futuresLongCrashFixture, testClient); // Move time past deliveryAt *before* the keeper boots, so the live - // subscription would miss the (long-past) LotCreated event. + // subscription would miss the (long-past) OrderMatched event. const deliveryAt = ctx.config.futuresFirstDeliveryDate; await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); await testClient.mine({ blocks: 1 }); @@ -1087,7 +1056,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readLotClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, alice, id)) !== null, `missed delivery for ${id} should be settled by backfill sweep`, ); } @@ -1101,7 +1070,7 @@ describe("DeliveryCoordinator (live RPC)", () => { // Production reality: on Alchemy free tier `eth_getLogs` is capped // at 10 blocks, so log-based backfill is unusable for any non-trivial // window. The view-based discovery path (`bootstrapFromUsers`) reads - // `getPositionIds` + `getPositionById` directly from contract storage, + // ``getActiveDeliveryDates` + `getUserPosition` directly from contract storage, // sidestepping the log limit entirely. This test exercises that exact // recovery shape: we never call `backfill()` — only `bootstrapFromUsers` // — and verify every still-alive position is found and settled. @@ -1119,7 +1088,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await keeper.delivery.bootstrapFromUsers([alice]); for (const id of positionsBefore) { - assert.ok(keeper.delivery.has(id), `bootstrap should index position ${id}`); + assert.ok(keeper.delivery.has(alice, BigInt(id)), `bootstrap should index position ${id}`); } const deliveryAt = ctx.config.futuresFirstDeliveryDate; @@ -1132,10 +1101,10 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readLotClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, alice, id)) !== null, `position ${id} should be settled via view-based bootstrap`, ); - assert.equal(keeper.delivery.has(id), false); + assert.equal(keeper.delivery.has(alice, BigInt(id)), false); } }, ); @@ -1185,7 +1154,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readLotClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, alice, id)) !== null, `manually-seeded position ${id} should be settled`, ); } @@ -1224,7 +1193,7 @@ describe("DeliveryCoordinator (live RPC)", () => { await expectFuturesClosed(ctx, alice); for (const id of positionsBefore) { assert.ok( - (await readLotClosedBlock(ctx, id)) !== null, + (await readLotClosedBlock(ctx, alice, id)) !== null, `position ${id} should be settled by a permissionless (non-validator) signer`, ); } diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index e853026..80815c5 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -498,8 +498,8 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const firstDeliveryAt = base.config.futuresFirstDeliveryDate; - const secondDeliveryAt = - firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_EXPIRATION_INTERVAL_DAYS`. + // Must match on-chain Futures.EXPIRATION_INTERVAL_DAYS (= 30). + const secondDeliveryAt = firstDeliveryAt + BigInt(30 * 24 * 3600); await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); @@ -608,7 +608,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const perExpiryQty = 6; const firstDeliveryAt = base.config.futuresFirstDeliveryDate; - const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_EXPIRATION_INTERVAL_DAYS + const secondDeliveryAt = firstDeliveryAt + BigInt(30 * 24 * 3600); // Futures.EXPIRATION_INTERVAL_DAYS // Zero the taker fee (see `futuresPartialCrashFixtureBuilder`) so the 12-lot // entry IM ($48) fits the $136 deposit; the liquidation fee still applies. @@ -967,9 +967,8 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { * 12-unit futures long ($50.40 loss, duration-free: 12 · ($4.21 − $0.01 mark)). * The planner must liquidate futures first. * - * The futures qty is capped at 12 because `createOrder` loops once per - * contract in the matching engine; larger values blow past Hardhat's - * per-tx gas cap (16M). + * Futures qty is a single signed createOrder in 3.0; 12 contracts remains a + * convenient fixture size for margin math (not a gas/looping constraint). */ export function crossVenueFuturesDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { @@ -1010,7 +1009,7 @@ interface FuturesTrade { seller: Wallet; price: bigint; deliveryAt: bigint; - /** int8 — number of contracts. */ + /** Whole contracts (signed at placement: +buy / −sell). */ quantity: number; } @@ -1044,13 +1043,12 @@ async function placeFuturesOrder( deliveryAt: bigint, qty: number, ): Promise { - // Futures takes a packed (price, deliveryDate, destURL, qty) tuple. - // `qty` is `int8` — positive = buyer-side, negative = seller-side. + // Futures 3.0: createOrder(price, deliveryAt, signedQuantity) — whole contracts. const hash = await wallet.client.writeContract({ address: base.addresses.futures, abi: base.abis.futures, functionName: "createOrder", - args: [price, deliveryAt, "//keeper-test", qty], + args: [price, deliveryAt, BigInt(qty)], chain: hardhat, account: wallet.account, }); diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 4ae47e7..0276a6a 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -127,7 +127,7 @@ function buildHarness({ return 0n; case "getFuturesOrderMargin": return 0n; - case "getPositionIds": + case "getActiveDeliveryDates": return []; case "computePortfolioIM": return balance / 2n; diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 918ff7b..9d75070 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -102,7 +102,7 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return 0n; case "getFuturesOrderMargin": return 0n; - case "getPositionIds": + case "getActiveDeliveryDates": return []; case "computePortfolioIM": return imAtPriceTokens(currentPrice); diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts index 5c28461..1b9183d 100644 --- a/keeper/tests/predict/mm.test.ts +++ b/keeper/tests/predict/mm.test.ts @@ -62,7 +62,7 @@ describe("predict/mm: netDeltaWad", () => { // Buyer of 1 contract → +1 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [{ deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], orderMargin: 0n, }, }); @@ -72,7 +72,7 @@ describe("predict/mm: netDeltaWad", () => { it("subtracts futures seller delta", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [{ deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], orderMargin: 0n, }, }); @@ -84,8 +84,8 @@ describe("predict/mm: netDeltaWad", () => { perp: { netQty: 1_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, // +1e18 futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, - { id: "0xbb", isBuyer: false, entryPricePerDay: 60n, deliveryAt: 1_756_416_000n }, + { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, + { deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -60n }, ], orderMargin: 0n, }, @@ -168,7 +168,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("buyer loses when P drops below entry (no duration factor)", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [{ deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], orderMargin: 0n, }, }); @@ -179,7 +179,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("seller loses when P rises above entry", () => { const snap = emptySnapshot({ futures: { - positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [{ deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], orderMargin: 0n, }, }); @@ -190,8 +190,8 @@ describe("predict/mm: futuresUnrealizedLoss", () => { const snap = emptySnapshot({ futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 → loses 10 - { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 → loses 10 + { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, // P=40 → loses 10 + { deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -30n }, // P=40 → loses 10 ], orderMargin: 0n, }, diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index 7af4f55..f1c0492 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -10,8 +10,9 @@ const PME = "0x000000000000000000000000000000000000aa02" as Address; const PERPS = "0x000000000000000000000000000000000000aa03" as Address; const FUTURES = "0x000000000000000000000000000000000000aa04" as Address; const USER = "0x1111111111111111111111111111111111111111" as Address; -const BUYER_POS_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const SELLER_POS_ID = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +const EXPIRY_A = 1_756_416_000n; +const EXPIRY_B = 1_759_008_000n; function makeConfig(): Config { return { @@ -22,18 +23,9 @@ function makeConfig(): Config { } as Config; } -/** - * Builds a chain stub with scripted multicall responses keyed on - * `functionName` — same pattern as the coordinator harness, kept local so - * each test reads as a self-contained record of the on-chain shape it - * exercises. - */ function makeChain(scripted: { - futuresPositionIds?: readonly string[]; - futuresPositions?: Record< - string, - { buyer: string; seller: string; buyPricePerDay: bigint; sellPricePerDay: bigint; deliveryAt: bigint } - >; + activeDeliveryDates?: readonly bigint[]; + futuresPositions?: Record; perpNetQty?: bigint; perpEntry?: bigint; perpOrderMargin?: bigint; @@ -50,31 +42,33 @@ function makeChain(scripted: { multicall: async ({ contracts, }: { - contracts: readonly { functionName: string; args?: readonly unknown[] }[]; + contracts: readonly { functionName: string; args?: readonly unknown[]; address?: Address }[]; }) => { return contracts.map((c) => { switch (c.functionName) { case "balanceOf": return scripted.balance ?? 0n; - case "getUserPosition": + case "getUserPosition": { + // Perps: getUserPosition(user). Futures: getUserPosition(user, deliveryAt). + if ((c.args?.length ?? 0) >= 2) { + const deliveryAt = c.args?.[1] as bigint; + const pos = scripted.futuresPositions?.[deliveryAt.toString()]; + if (pos === undefined) throw new Error(`unscripted futures position ${deliveryAt}`); + return pos; + } return { netQuantity: scripted.perpNetQty ?? 0n, aggregatedEntryPrice: scripted.perpEntry ?? 0n, }; + } case "getOrderMargin": return scripted.perpOrderMargin ?? 0n; case "getPendingFunding": return scripted.perpFunding ?? 0n; case "getFuturesOrderMargin": return scripted.futuresOrderMargin ?? 0n; - case "getPositionIds": - return scripted.futuresPositionIds ?? []; - case "getPositionById": { - const id = c.args?.[0] as string; - const pos = scripted.futuresPositions?.[id]; - if (pos === undefined) throw new Error(`unscripted position ${id}`); - return pos; - } + case "getActiveDeliveryDates": + return scripted.activeDeliveryDates ?? []; case "imSpotShock": return scripted.imShock ?? 10n ** 17n; case "mmSpotShock": @@ -125,35 +119,21 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.perp.fundingOwed, 1_000n); }); - it("hydrates futures positions and assigns isBuyer based on the buyer field", async () => { + it("hydrates futures aggregates from active delivery dates", async () => { const chain = makeChain({ - futuresPositionIds: [BUYER_POS_ID, SELLER_POS_ID], + activeDeliveryDates: [EXPIRY_A, EXPIRY_B], futuresPositions: { - [BUYER_POS_ID]: { - buyer: USER, - seller: "0x000000000000000000000000000000000000feed", - buyPricePerDay: 50n, - sellPricePerDay: 51n, - deliveryAt: 1_756_416_000n, - }, - [SELLER_POS_ID]: { - buyer: "0x000000000000000000000000000000000000feed", - seller: USER, - buyPricePerDay: 60n, - sellPricePerDay: 59n, - deliveryAt: 1_759_008_000n, - }, + [EXPIRY_A.toString()]: { netQuantity: 1n, netEntryValue: 50n }, + [EXPIRY_B.toString()]: { netQuantity: -2n, netEntryValue: -118n }, }, }); const snap = await readAccountSnapshot(chain, makeConfig(), USER); assert.equal(snap.futures.positions.length, 2); - const buyer = snap.futures.positions.find((p) => p.id === BUYER_POS_ID); - const seller = snap.futures.positions.find((p) => p.id === SELLER_POS_ID); - assert.equal(buyer?.isBuyer, true); - assert.equal(buyer?.entryPricePerDay, 50n); - assert.equal(buyer?.deliveryAt, 1_756_416_000n); - assert.equal(seller?.isBuyer, false); - assert.equal(seller?.entryPricePerDay, 59n); - assert.equal(seller?.deliveryAt, 1_759_008_000n); + const long = snap.futures.positions.find((p) => p.deliveryAt === EXPIRY_A); + const short = snap.futures.positions.find((p) => p.deliveryAt === EXPIRY_B); + assert.equal(long?.netQuantity, 1n); + assert.equal(long?.netEntryValue, 50n); + assert.equal(short?.netQuantity, -2n); + assert.equal(short?.netEntryValue, -118n); }); }); diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts index 72e6dba..53ff4ae 100644 --- a/keeper/tests/predict/solve.test.ts +++ b/keeper/tests/predict/solve.test.ts @@ -146,7 +146,7 @@ describe("predict/solve: solveLiquidationThresholds", () => { balance: 30_000_000n, futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50_000_000n, deliveryAt: 1_756_416_000n }, + { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50_000_000n }, ], orderMargin: 0n, }, diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts index 7eb5997..455eda1 100644 --- a/keeper/tests/predict/solveTarget.test.ts +++ b/keeper/tests/predict/solveTarget.test.ts @@ -1,19 +1,17 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; import { simulateFuturesClose, simulatePerpClose, - solveFuturesLotsToTarget, + solveFuturesClosesToTarget, solvePerpCloseToTarget, } from "../../src/predict/solve.ts"; import { imSurplus, mmSurplus } from "../../src/predict/mm.ts"; -import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; +import type { AccountSnapshot, FuturesCloseLeg, MMParams } from "../../src/predict/types.ts"; const USER = "0x1111111111111111111111111111111111111111" as Address; -const QTY_SCALE = 10n ** 6n; -// PME defaults used across the stack: 10% IM / 5% MM, USDC 6-dec, perps qty 6-dec. const PARAMS: MMParams = { imSpotShock: 10n ** 17n, mmSpotShock: 5n * 10n ** 16n, @@ -23,22 +21,19 @@ const PARAMS: MMParams = { const FEE = 1_000_000n; // $1 flat liquidation fee -// Default single-expiry timestamp for lots whose test doesn't care about the -// expiration grouping (keeps their behaviour identical to pre-balancing). const EXPIRY_A = 1_756_416_000n; const EXPIRY_B = 1_759_008_000n; -// Duration-free contract sizing. Each lot is a single contract that settles -// `entryPricePerDay` of notional (no `× deliveryDays` factor). For a close to -// improve MM surplus the stress it frees (`spotShock × P`) must exceed the flat -// fee, so the moderate-crash price sits well above `20 × FEE` — hence the -// $40/$30 magnitudes below rather than the old sub-dollar per-day prices. -const ENTRY_PER_DAY = 40_000_000n; // $40/day entry -const P_MODERATE = 30_000_000n; // $30/day: underwater but recoverable via a subset -const BALANCE = 136_000_000n; // collateral: underwater at P_MODERATE, healable by a partial close - -function futuresLot(id: Hex, entryPricePerDay: bigint, isBuyer = true, deliveryAt = EXPIRY_A) { - return { id, isBuyer, entryPricePerDay, deliveryAt }; +const ENTRY = 40_000_000n; // $40/contract entry +const P_MODERATE = 30_000_000n; // $30: underwater but recoverable +const BALANCE = 136_000_000n; + +function futuresAgg(netQuantity: bigint, entry: bigint, deliveryAt = EXPIRY_A) { + return { + deliveryAt, + netQuantity, + netEntryValue: entry * netQuantity, + }; } function futuresSnapshot(overrides: Partial = {}): AccountSnapshot { @@ -51,187 +46,145 @@ function futuresSnapshot(overrides: Partial = {}): AccountSnaps }; } -/** 12 identical $40/day long lots — the integration `futuresPartialCrash` shape. */ -function twelveLongLots(): AccountSnapshot { - const positions = []; - for (let i = 0; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY)); - } - return futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); +/** 12-contract long aggregate — same economics as the old twelve-lot fixture. */ +function twelveLong(): AccountSnapshot { + return futuresSnapshot({ + balance: BALANCE, + futures: { positions: [futuresAgg(12n, ENTRY)], orderMargin: 0n }, + }); } -describe("predict/solve: solveFuturesLotsToTarget", () => { +function totalCloseQty(closes: readonly FuturesCloseLeg[]): bigint { + return closes.reduce((s, c) => s + c.closeQty, 0n); +} + +describe("predict/solve: solveFuturesClosesToTarget", () => { it("returns an empty set when the account is already healthy", () => { const snap = futuresSnapshot({ balance: 1_000_000_000n, - futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, ENTRY_PER_DAY)], orderMargin: 0n }, + futures: { positions: [futuresAgg(1n, ENTRY)], orderMargin: 0n }, }); - const ids = solveFuturesLotsToTarget(snap, PARAMS, P_MODERATE, FEE); - assert.equal(ids.length, 0); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P_MODERATE, FEE); + assert.equal(closes.length, 0); }); - it("closes a strict worst-first subset that lands inside the [MM, IM] band", () => { - const snap = twelveLongLots(); - const P = P_MODERATE; // moderate crash → underwater but recoverable - - // Precondition: the account really is underwater at P. + it("closes a strict subset that lands inside the [MM, IM] band", () => { + const snap = twelveLong(); + const P = P_MODERATE; assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); - const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); - assert.ok(ids.length > 0, "should close at least one lot"); - assert.ok(ids.length < snap.futures.positions.length, "should leave >=1 lot open (strict subset)"); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + const qty = totalCloseQty(closes); + assert.ok(qty > 0n, "should close at least one contract"); + assert.ok(qty < 12n, "should leave >=1 contract open (strict subset)"); - // The chosen subset lands the account in the [MM, IM] buffer band. - const after = simulateFuturesClose(snap, ids, P, FEE); + const after = simulateFuturesClose(snap, closes, P, FEE); assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); - assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM (no over-liquidation)"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); }); - it("is the DEEPEST in-band subset — closing one more worst-first lot breaches IM", () => { - const snap = twelveLongLots(); + it("is the DEEPEST in-band close — one more contract breaches IM", () => { + const snap = twelveLong(); const P = P_MODERATE; - const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); - - // There is still a lot to add and doing so would push balance over IM. - if (ids.length < snap.futures.positions.length - 1) { - const remaining = snap.futures.positions.find((p) => !ids.includes(p.id)); - assert.ok(remaining, "expected a remaining lot to test the deepest boundary"); - const oneMore = simulateFuturesClose(snap, [...ids, remaining.id], P, FEE); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + const qty = totalCloseQty(closes); + if (qty < 11n) { + const oneMore: FuturesCloseLeg[] = [ + { deliveryAt: EXPIRY_A, closeQty: qty + 1n }, + ]; + const after = simulateFuturesClose(snap, oneMore, P, FEE); assert.ok( - imSurplus(oneMore, PARAMS, P) > 0n, - "closing one more lot should overshoot IM (proves the subset is the deepest)", + imSurplus(after, PARAMS, P) > 0n, + "closing one more contract should overshoot IM", ); } }); - it("returns the full set (all ids) on a deep crash with no in-band subset", () => { - const snap = twelveLongLots(); - const P = 100_000n; // ~98% crash → bad debt even after closing everything - const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); - assert.equal(ids.length, snap.futures.positions.length, "deep crash fully closes"); + it("returns a full close on a deep crash with no in-band subset", () => { + const snap = twelveLong(); + const P = 100_000n; + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + assert.equal(totalCloseQty(closes), 12n, "deep crash fully closes"); }); it("degenerate IM == MM: targets minimal healthy (no upper IM bound)", () => { - const snap = twelveLongLots(); + const snap = twelveLong(); const P = P_MODERATE; const degenerate: MMParams = { ...PARAMS, imSpotShock: PARAMS.mmSpotShock }; - const ids = solveFuturesLotsToTarget(snap, degenerate, P, FEE); - assert.ok(ids.length > 0 && ids.length <= snap.futures.positions.length); - const after = simulateFuturesClose(snap, ids, P, FEE); + const closes = solveFuturesClosesToTarget(snap, degenerate, P, FEE); + const qty = totalCloseQty(closes); + assert.ok(qty > 0n && qty <= 12n); + const after = simulateFuturesClose(snap, closes, P, FEE); assert.ok(mmSurplus(after, degenerate, P) >= 0n, "healthy at MM"); }); - it("balances the close across futures expirations (does not drain one expiry's book)", () => { - // 12 identical $4.21/day long lots split evenly across two expiration - // dates (markets). With equal per-lot loss, an expiry-blind worst-first - // solver would just take a prefix in input order — draining EXPIRY_A - // entirely before touching EXPIRY_B. The balanced solver must instead - // spread the closures across both books. - const positions: AccountSnapshot["futures"]["positions"] = []; - for (let i = 0; i < 6; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); - } - for (let i = 6; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); - } - const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + it("balances the close across futures expirations", () => { + const snap = futuresSnapshot({ + balance: BALANCE, + futures: { + positions: [ + futuresAgg(6n, ENTRY, EXPIRY_A), + futuresAgg(6n, ENTRY, EXPIRY_B), + ], + orderMargin: 0n, + }, + }); const P = P_MODERATE; - assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); - - const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); - assert.ok(ids.length > 1, "should close more than one lot so balancing is observable"); + assert.ok(mmSurplus(snap, PARAMS, P) < 0n); - const byExpiry = (deliveryAt: bigint) => - ids.filter((id) => positions.find((p) => p.id === id)?.deliveryAt === deliveryAt).length; - const countA = byExpiry(EXPIRY_A); - const countB = byExpiry(EXPIRY_B); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + assert.ok(totalCloseQty(closes) > 1n); - assert.ok(countA >= 1 && countB >= 1, `both expirations must be reduced (A=${countA}, B=${countB})`); + const countA = closes.find((c) => c.deliveryAt === EXPIRY_A)?.closeQty ?? 0n; + const countB = closes.find((c) => c.deliveryAt === EXPIRY_B)?.closeQty ?? 0n; + assert.ok(countA >= 1n && countB >= 1n, `both expirations must be reduced (A=${countA}, B=${countB})`); assert.ok( - countA - countB <= 1 && countB - countA <= 1, - `closures must be balanced across expirations within one lot (A=${countA}, B=${countB})`, + countA - countB <= 1n && countB - countA <= 1n, + `closures must be balanced within one contract (A=${countA}, B=${countB})`, ); - // Still lands in the [MM, IM] band — balancing must not sacrifice the target. - const after = simulateFuturesClose(snap, ids, P, FEE); - assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); - assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); + const after = simulateFuturesClose(snap, closes, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n); + assert.ok(imSurplus(after, PARAMS, P) <= 0n); }); - it("balances proportionally to each expiry's book size when expiries differ in size", () => { - // EXPIRY_A holds 8 lots, EXPIRY_B holds 4 lots (2:1). A balanced close - // should reduce them roughly in proportion — A closes about twice as many - // lots as B — rather than emptying the smaller book first. - const positions: AccountSnapshot["futures"]["positions"] = []; - for (let i = 0; i < 8; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); - } - for (let i = 8; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); - } - const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + it("balances proportionally when expiries differ in size", () => { + const snap = futuresSnapshot({ + balance: BALANCE, + futures: { + positions: [ + futuresAgg(8n, ENTRY, EXPIRY_A), + futuresAgg(4n, ENTRY, EXPIRY_B), + ], + orderMargin: 0n, + }, + }); const P = P_MODERATE; - - const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); - const byExpiry = (deliveryAt: bigint) => - ids.filter((id) => positions.find((p) => p.id === id)?.deliveryAt === deliveryAt).length; - const countA = byExpiry(EXPIRY_A); - const countB = byExpiry(EXPIRY_B); - - // The larger book (A, 2×) is reduced at least as much as the smaller (B), - // and the smaller book is not fully drained while the larger is untouched. - assert.ok(countA >= countB, `larger book should not close fewer (A=${countA}, B=${countB})`); - assert.ok(countB >= 1, "smaller book still participates"); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + const countA = closes.find((c) => c.deliveryAt === EXPIRY_A)?.closeQty ?? 0n; + const countB = closes.find((c) => c.deliveryAt === EXPIRY_B)?.closeQty ?? 0n; + // A is twice B → roughly 2:1 close ratio when both are touched. + if (countA > 0n && countB > 0n) { + assert.ok(countA >= countB, `A=${countA} should close at least as many as B=${countB}`); + } }); }); -function perpSnapshot(netQty: bigint, entryPrice: bigint, balance: bigint): AccountSnapshot { - return { - user: USER, - balance, - perp: { netQty, entryPrice, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n }, - }; -} - -describe("predict/solve: solvePerpCloseToTarget", () => { - it("returns 0 when the account is already healthy", () => { - const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 1_000_000_000n); - assert.equal(solvePerpCloseToTarget(snap, PARAMS, 3_000_000n, FEE), 0n); - }); - - it("returns a partial closeQty that lands inside the [MM, IM] band", () => { - // Long 40 @ $4.21, deposit $52, crash to $3.00 — the integration - // `perpsPartialCrash` shape. - const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 52_000_000n); - const P = 3_000_000n; - assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); - - const q = solvePerpCloseToTarget(snap, PARAMS, P, FEE); - const absNet = 40n * QTY_SCALE; - assert.ok(q > 0n, "should close a positive quantity"); - assert.ok(q < absNet, "should be a PARTIAL close (residual position remains)"); - - const after = simulatePerpClose(snap, q, P, FEE); - assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); - assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); - }); - - it("returns the full quantity on a deep crash (bad-debt full close)", () => { - const snap = perpSnapshot(40n * QTY_SCALE, 4_210_000n, 52_000_000n); - const q = solvePerpCloseToTarget(snap, PARAMS, 100_000n, FEE); - assert.equal(q, 40n * QTY_SCALE, "deep crash fully closes"); +describe("predict/solve: solvePerpCloseToTarget (smoke)", () => { + it("returns 0 when healthy", () => { + const snap = futuresSnapshot({ + balance: 1_000_000_000n, + perp: { netQty: 1_000_000n, entryPrice: ENTRY, orderMargin: 0n, fundingOwed: 0n }, + }); + assert.equal(solvePerpCloseToTarget(snap, PARAMS, P_MODERATE, FEE), 0n); }); - it("handles a short position (pump) symmetrically", () => { - // Short 40 @ $4.21, deposit $52, pump to $5.47 (+30%). - const snap = perpSnapshot(-40n * QTY_SCALE, 4_210_000n, 52_000_000n); - const P = 5_470_000n; - assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "short must start underwater on the pump"); - const q = solvePerpCloseToTarget(snap, PARAMS, P, FEE); - assert.ok(q > 0n && q < 40n * QTY_SCALE, "partial close of the short"); - const after = simulatePerpClose(snap, q, P, FEE); - assert.ok(mmSurplus(after, PARAMS, P) >= 0n); - assert.ok(imSurplus(after, PARAMS, P) <= 0n); + it("simulatePerpClose reduces qty toward zero", () => { + const snap = futuresSnapshot({ + balance: BALANCE, + perp: { netQty: 5_000_000n, entryPrice: ENTRY, orderMargin: 0n, fundingOwed: 0n }, + }); + const after = simulatePerpClose(snap, 2_000_000n, P_MODERATE, FEE); + assert.equal(after.perp.netQty, 3_000_000n); }); }); diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index ea4e6c0..2f3c568 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -89,7 +89,7 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { args: unknown[]; }) => { recorded.readContractCalls++; - if (functionName !== "getOrderIds") { + if (functionName !== "getUserOrders") { throw new Error(`unexpected readContract: ${functionName}`); } const user = args[0] as Address; @@ -102,7 +102,7 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { }) => { recorded.multicallReadCalls++; return contracts.map((c) => { - if (c.functionName !== "getOrderById") { + if (c.functionName !== "getOrder") { throw new Error(`unexpected multicall fn: ${c.functionName}`); } const id = c.args[0] as Hex; @@ -371,7 +371,7 @@ describe("OutdatedOrderSweeper", () => { }); it("drops stale-state candidates flagged by simulate (OrderNotExists / OrderNotExpired)", async () => { - // Race scenario: between our `getOrderById` read and our simulate, the + // Race scenario: between our `getOrder` read and our simulate, the // user (or a concurrent keeper) closed orderId1, and orderId2 had its // deliveryAt bumped. The sweeper must skip them silently and still // broadcast a write for the survivor (orderId3). @@ -439,7 +439,7 @@ describe("OutdatedOrderSweeper", () => { assert.ok(calls.some((c) => c.msg.startsWith("[dryRun]"))); }); - it("does not crash when one user's getOrderIds fails — continues with the next user", async () => { + 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; @@ -449,7 +449,7 @@ describe("OutdatedOrderSweeper", () => { ]); const blockTimestamp = 10_000n; - // Custom chain that fails getOrderIds(USER_A) only. + // Custom chain that fails getUserOrders(USER_A) only. const recorded: Recorded = { readContractCalls: 0, multicallReadCalls: 0, @@ -523,7 +523,7 @@ describe("OutdatedOrderSweeper", () => { ); assert.ok( calls.some( - (c) => c.level === "warn" && c.msg.includes("getOrderIds failed"), + (c) => c.level === "warn" && c.msg.includes("getUserOrders failed"), ), "expected a warn log for the failed user", ); diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts index 71d0b36..a76b492 100644 --- a/keeper/tests/venues/futures.test.ts +++ b/keeper/tests/venues/futures.test.ts @@ -7,7 +7,6 @@ import type { Config } from "../../src/config.ts"; const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; const BUYER = "0x0000000000000000000000000000000000000b0b" as Address; -const SELLER = "0x0000000000000000000000000000000000005e11" as Address; interface ReadCall { functionName: string; @@ -46,13 +45,14 @@ const silentLogger = { error: () => undefined, } as unknown as ConstructorParameters[2]; -const DELIVERY_AT = 1_756_416_000n; // 2025-08-28T18:40:00Z (slice(0,10) → "2025-08-28") +const DELIVERY_AT = 1_756_416_000n; -/** Reusable stub: market price + (positionIds | orderIds) reads. */ -function makeReadHandler(marketPrice: bigint, listResult: readonly Hex[]) { +function makeReadHandler(marketPrice: bigint, listResult: readonly unknown[]) { return (call: ReadCall): unknown => { if (call.functionName === "getMarketPrice") return marketPrice; - if (call.functionName === "getOrderIds" || call.functionName === "getPositionIds") return listResult; + if (call.functionName === "getUserOrders" || call.functionName === "getActiveDeliveryDates") { + return listResult; + } throw new Error(`unexpected readContract call: ${call.functionName}`); }; } @@ -66,7 +66,7 @@ describe("futures venue: marketLabel", () => { }); describe("futures venue: readOpenOrders", () => { - it("returns empty when getOrderIds is empty (no extra multicall)", async () => { + it("returns empty when getUserOrders is empty (no extra multicall)", async () => { let multicallCount = 0; const chain = makeChainStub({ readContract: makeReadHandler(100n, []), @@ -89,12 +89,11 @@ describe("futures venue: readOpenOrders", () => { const chain = makeChainStub({ readContract: makeReadHandler(100n, orderIds), multicall: (calls) => { - // One getOrderById per order id, in order. assert.equal(calls.length, 2); - for (const c of calls) assert.equal(c.functionName, "getOrderById"); + for (const c of calls) assert.equal(c.functionName, "getOrder"); return [ - { isBuy: true, participant: BUYER, deliveryAt: DELIVERY_AT, pricePerDay: 50n }, - { isBuy: false, participant: BUYER, deliveryAt: DELIVERY_AT + 86_400n, pricePerDay: 60n }, + { participant: BUYER, deliveryAt: DELIVERY_AT, price: 50n, quantity: 1n }, + { participant: BUYER, deliveryAt: DELIVERY_AT + 86_400n, price: 60n, quantity: -1n }, ]; }, }); @@ -108,7 +107,7 @@ describe("futures venue: readOpenOrders", () => { }); describe("futures venue: readPositions", () => { - it("returns empty when getPositionIds is empty", async () => { + it("returns empty when getActiveDeliveryDates is empty", async () => { const chain = makeChainStub({ readContract: makeReadHandler(100n, []), multicall: () => [], @@ -118,76 +117,36 @@ describe("futures venue: readPositions", () => { assert.equal(positions.length, 0); }); - it("computes long-side underwater PnL for a buyer when market drops below entry", async () => { - const positionIds: Hex[] = ["0x" + "11".repeat(32) as Hex]; - const buyPx = 100n; - const sellPx = 100n; - const marketPrice = 70n; // long → loses (100-70) = 30 per contract (no duration factor) + it("computes long-side underwater PnL when market drops below entry", async () => { + const entry = 100n; + const marketPrice = 70n; const chain = makeChainStub({ - readContract: makeReadHandler(marketPrice, positionIds), + readContract: makeReadHandler(marketPrice, [DELIVERY_AT]), multicall: (calls) => { assert.equal(calls.length, 1); - assert.equal(calls[0]?.functionName, "getPositionById"); - return [ - { - seller: SELLER, - buyer: BUYER, - buyPricePerDay: buyPx, - sellPricePerDay: sellPx, - deliveryAt: DELIVERY_AT, - }, - ]; + assert.equal(calls[0]?.functionName, "getUserPosition"); + return [{ netQuantity: 1n, netEntryValue: entry }]; }, }); const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(BUYER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, buyPx - marketPrice); - assert.equal(pos.notional, buyPx); + assert.equal(pos.unrealizedLoss, entry - marketPrice); + assert.equal(pos.notional, entry); assert.equal(pos.marketId, deliveryAtMarketId(DELIVERY_AT)); }); - it("computes short-side underwater PnL for a seller when market rises above entry", async () => { - const positionIds: Hex[] = ["0x" + "22".repeat(32) as Hex]; - const sellPx = 100n; - const buyPx = 100n; - const marketPrice = 130n; // short → loses (130-100) = 30 per contract (no duration factor) - const chain = makeChainStub({ - readContract: makeReadHandler(marketPrice, positionIds), - multicall: () => [ - { - seller: SELLER, - buyer: BUYER, - buyPricePerDay: buyPx, - sellPricePerDay: sellPx, - deliveryAt: DELIVERY_AT, - }, - ], - }); - const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); - const [pos] = await venue.readPositions(SELLER); - assert.ok(pos); - assert.equal(pos.unrealizedLoss, marketPrice - sellPx); - assert.equal(pos.notional, sellPx); - }); - - it("reports zero loss when the user is in profit", async () => { - const positionIds: Hex[] = ["0x" + "33".repeat(32) as Hex]; + it("computes short-side underwater PnL when market rises above entry", async () => { + const entry = 100n; + const marketPrice = 130n; const chain = makeChainStub({ - readContract: makeReadHandler(150n, positionIds), - multicall: () => [ - { - seller: SELLER, - buyer: BUYER, - buyPricePerDay: 100n, - sellPricePerDay: 100n, - deliveryAt: DELIVERY_AT, - }, - ], + readContract: makeReadHandler(marketPrice, [DELIVERY_AT]), + multicall: () => [{ netQuantity: -1n, netEntryValue: -entry }], }); const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(BUYER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, 0n, "buyer with market > entry is in profit"); + assert.equal(pos.unrealizedLoss, marketPrice - entry); + assert.equal(pos.notional, entry); }); }); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index ce55a4b..50d2619 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -1,23 +1,18 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import type { Address, Hex } from "viem"; +import type { Address } from "viem"; import { FuturesVenue } from "../../src/venues/futures.ts"; -import { PerpsVenue } from "../../src/venues/perps.ts"; import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; -const USER = "0x1111111111111111111111111111111111111111" as Address; -const VAULT = "0x000000000000000000000000000000000000000a" as Address; -const PME = "0x000000000000000000000000000000000000000b" as Address; -const PERPS = "0x000000000000000000000000000000000000c0de" as Address; -const FUTURES = "0x000000000000000000000000000000000000f00d" as Address; +const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; +const USER = "0x0000000000000000000000000000000000000b0b" as Address; +const EXPIRY = 1_756_416_000n; -// PME defaults: 10% IM / 5% MM, USDC 6-dec, perps qty 6-dec (real IM buffer). const IM_SHOCK = 10n ** 17n; const MM_SHOCK = 5n * 10n ** 16n; interface ReadCall { - address: Address; functionName: string; args?: readonly unknown[]; } @@ -30,30 +25,22 @@ const silentLogger = { error: () => undefined, } as unknown as ConstructorParameters[2]; -function makeConfigStub(dryRun: boolean, maxLotsPerLiquidationTx?: number): Config { +function makeConfigStub(dryRun: boolean, maxLots = 50): Config { return { - vault: { address: VAULT }, - pme: { address: PME }, - perps: { address: PERPS }, - futures: { address: FUTURES, maxLotsPerLiquidationTx }, - coordinator: { confirmationBlocks: 1 }, + futures: { address: FUTURES, maxLotsPerLiquidationTx: maxLots }, + vault: { address: "0x000000000000000000000000000000000000aa01" as Address }, + pme: { address: "0x000000000000000000000000000000000000aa02" as Address }, + perps: { address: "0x000000000000000000000000000000000000aa03" as Address }, keeper: { dryRun }, + coordinator: { confirmationBlocks: 1 }, } as Config; } -/** - * Chain stub that serves the two snapshot round-trips (readMMParams + - * readAccountSnapshot), the market-price / liquidation-fee reads, and records - * the `simulateContract` call so tests can assert the batched calldata the - * venue sizes. `dryRun: true` means `sendLiquidate` never writes a tx. - */ function makeChainStub(opts: { balance: bigint; marketPrice: bigint; - liquidationFee: bigint; - perp: { netQuantity: bigint; aggregatedEntryPrice: bigint }; - futuresPositionIds: readonly Hex[]; - futuresPosition?: { buyer: Address; buyPricePerDay: bigint; sellPricePerDay: bigint }; + netQuantity: bigint; + netEntryValue: bigint; onSimulate: (call: ReadCall) => void; }): Chain { return { @@ -61,34 +48,25 @@ function makeChainStub(opts: { publicClient: { readContract: async (call: ReadCall) => { if (call.functionName === "getMarketPrice") return opts.marketPrice; - if (call.functionName === "liquidationFee") return opts.liquidationFee; throw new Error(`unexpected readContract: ${call.functionName}`); }, multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => { const fns = contracts.map((c) => c.functionName); - // readMMParams if (fns[0] === "imSpotShock") return [IM_SHOCK, MM_SHOCK, 6, 6]; - // readAccountSnapshot bulk read if (fns[0] === "balanceOf") { return [ opts.balance, - { netQuantity: opts.perp.netQuantity, aggregatedEntryPrice: opts.perp.aggregatedEntryPrice }, - 0n, // getOrderMargin - 0n, // getPendingFunding - 0n, // getFuturesOrderMargin - opts.futuresPositionIds, + { netQuantity: 0n, aggregatedEntryPrice: 0n }, + 0n, + 0n, + 0n, + [EXPIRY], ]; } - // readAccountSnapshot per-position hydration - if (fns[0] === "getPositionById") { - const p = opts.futuresPosition; - if (p === undefined) throw new Error("no futuresPosition scripted"); + if (fns[0] === "getUserPosition") { return contracts.map(() => ({ - seller: "0x0000000000000000000000000000000000005e11" as Address, - buyer: p.buyer, - buyPricePerDay: p.buyPricePerDay, - sellPricePerDay: p.sellPricePerDay, - deliveryAt: 1_756_416_000n, + netQuantity: opts.netQuantity, + netEntryValue: opts.netEntryValue, })); } throw new Error(`unexpected multicall head: ${fns[0]}`); @@ -102,19 +80,13 @@ function makeChainStub(opts: { } describe("futures venue: reduceToTarget", () => { - it("sizes a strict worst-first lot subset and submits one liquidatePositions batch", async () => { - // 12 long lots @ $40/day, $136 deposit, crash to $30 — underwater but - // recoverable (mirrors the solver's in-band fixture; no duration factor). - const ids: Hex[] = []; - for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); + it("sizes a closeQty and submits liquidatePositions(deliveryAts, closeQtys)", async () => { let simulated: ReadCall | undefined; const chain = makeChainStub({ balance: 136_000_000n, marketPrice: 30_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, - futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, + netQuantity: 12n, + netEntryValue: 12n * 40_000_000n, onSimulate: (call) => { simulated = call; }, @@ -124,120 +96,80 @@ describe("futures venue: reduceToTarget", () => { assert.ok(simulated, "should simulate a liquidatePositions call"); assert.equal(simulated?.functionName, "liquidatePositions"); - const [participant, batch] = simulated?.args as [Address, Hex[]]; + const [participant, deliveryAts, closeQtys] = simulated?.args as [ + Address, + bigint[], + bigint[], + ]; assert.equal(participant, USER); - assert.ok(batch.length > 0 && batch.length < ids.length, "strict subset of lots"); - // dryRun → no fee, but the planner still learns how many lots closed. - assert.ok("feeEarned" in outcome && outcome.positionsClosed === batch.length); - }); - - it("caps the batch to maxLotsPerLiquidationTx (gas-bounded chunking)", async () => { - // 12 long lots @ $40/day, $136 deposit, crash to $1.00 — a deep crash the - // solver resolves to a FULL close (all 12 ids). With a cap below 12, - // `reduceToTarget` must send only the worst-first prefix and report - // `positionsClosed` == cap; the planner loop drains the rest next iteration. - const ids: Hex[] = []; - for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); - - // Uncapped target first, so the assertion is robust to the solver's sizing. - let full: Hex[] = []; - const chainFull = makeChainStub({ - balance: 136_000_000n, - marketPrice: 1_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, - futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, - onSimulate: (call) => { - full = (call.args as [Address, Hex[]])[1]; - }, - }); - await new FuturesVenue(chainFull, makeConfigStub(true), silentLogger).reduceToTarget(USER); - assert.ok(full.length >= 2, `scenario should want ≥2 lots so the cap bites (got ${full.length})`); - - const cap = full.length - 1; - let chunk: Hex[] = []; - const chainCap = makeChainStub({ - balance: 136_000_000n, - marketPrice: 1_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, - futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, - onSimulate: (call) => { - chunk = (call.args as [Address, Hex[]])[1]; - }, - }); - const outcome = await new FuturesVenue(chainCap, makeConfigStub(true, cap), silentLogger).reduceToTarget( - USER, - ); - - assert.equal(chunk.length, cap, "batch capped to maxLotsPerLiquidationTx"); - assert.deepEqual(chunk, full.slice(0, cap), "sends the worst-first prefix of the full target"); - assert.ok("feeEarned" in outcome && outcome.positionsClosed === cap); - }); - - it("skips with nothingToClose when the account is already at/above the IM buffer", async () => { - const ids: Hex[] = [("0x" + "01".repeat(32)) as Hex]; - let simulateCalled = false; - const chain = makeChainStub({ - balance: 1_000_000_000n, // fully collateralised - marketPrice: 30_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, - futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, - onSimulate: () => { - simulateCalled = true; - }, - }); - const venue = new FuturesVenue(chain, makeConfigStub(true), silentLogger); - const outcome = await venue.reduceToTarget(USER); - assert.deepEqual(outcome, { skipped: "nothingToClose" }); - assert.equal(simulateCalled, false, "no tx simulated when nothing to close"); + assert.equal(deliveryAts.length, 1); + assert.equal(deliveryAts[0], EXPIRY); + assert.ok(closeQtys[0]! > 0n && closeQtys[0]! < 12n, "strict subset of contracts"); + assert.ok("feeEarned" in outcome && outcome.positionsClosed === Number(closeQtys[0])); }); -}); -describe("perps venue: reduceToTarget", () => { - it("sizes a partial closeQty and submits one liquidatePosition call", async () => { - // Long 40 @ $4.21, $52 deposit, crash to $3.00 — underwater, partial-recoverable. + it("caps the batch to maxLotsPerLiquidationTx (expiry-leg chunking)", async () => { + const EXPIRY_B = EXPIRY + 86_400n; let simulated: ReadCall | undefined; - const chain = makeChainStub({ - balance: 52_000_000n, - marketPrice: 3_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 40n * 10n ** 6n, aggregatedEntryPrice: 4_210_000n }, - futuresPositionIds: [], - onSimulate: (call) => { - simulated = call; + const chain = { + account: { address: "0x0000000000000000000000000000000000009999" as Address }, + publicClient: { + readContract: async (call: ReadCall) => { + if (call.functionName === "getMarketPrice") return 100_000n; + throw new Error(`unexpected readContract: ${call.functionName}`); + }, + multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => { + const fns = contracts.map((c) => c.functionName); + if (fns[0] === "imSpotShock") return [IM_SHOCK, MM_SHOCK, 6, 6]; + if (fns[0] === "balanceOf") { + return [ + 1_000_000n, + { netQuantity: 0n, aggregatedEntryPrice: 0n }, + 0n, + 0n, + 0n, + [EXPIRY, EXPIRY_B, EXPIRY + 172_800n], + ]; + } + if (fns[0] === "getUserPosition") { + return contracts.map((c) => { + const deliveryAt = c.args?.[1] as bigint; + return { + netQuantity: 4n, + netEntryValue: 4n * 40_000_000n, + _deliveryAt: deliveryAt, + }; + }); + } + throw new Error(`unexpected multicall head: ${fns[0]}`); + }, + simulateContract: async (call: ReadCall) => { + simulated = call; + return { request: { ...call } }; + }, }, - }); - const venue = new PerpsVenue(chain, makeConfigStub(true), silentLogger); - const outcome = await venue.reduceToTarget(USER); + } as unknown as Chain; - assert.ok(simulated, "should simulate a liquidatePosition call"); - assert.equal(simulated?.functionName, "liquidatePosition"); - const [user, closeQty] = simulated?.args as [Address, bigint]; - assert.equal(user, USER); - assert.ok(closeQty > 0n && closeQty < 40n * 10n ** 6n, "partial close (residual remains)"); - assert.ok("feeEarned" in outcome && outcome.positionsClosed === 1); + const venue = new FuturesVenue(chain, makeConfigStub(true, 2), silentLogger); + const outcome = await venue.reduceToTarget(USER); + assert.ok(simulated); + const [, deliveryAts] = simulated?.args as [Address, bigint[], bigint[]]; + assert.equal(deliveryAts.length, 2, "capped to 2 expiry legs"); + assert.ok("feeEarned" in outcome); }); - it("skips with nothingToClose when the perps account is already healthy", async () => { - let simulateCalled = false; + it("returns nothingToClose when already healthy", async () => { const chain = makeChainStub({ balance: 1_000_000_000n, - marketPrice: 3_000_000n, - liquidationFee: 1_000_000n, - perp: { netQuantity: 40n * 10n ** 6n, aggregatedEntryPrice: 4_210_000n }, - futuresPositionIds: [], + marketPrice: 30_000_000n, + netQuantity: 1n, + netEntryValue: 40_000_000n, onSimulate: () => { - simulateCalled = true; + throw new Error("should not simulate"); }, }); - const venue = new PerpsVenue(chain, makeConfigStub(true), silentLogger); + const venue = new FuturesVenue(chain, makeConfigStub(true), silentLogger); const outcome = await venue.reduceToTarget(USER); assert.deepEqual(outcome, { skipped: "nothingToClose" }); - assert.equal(simulateCalled, false); }); }); diff --git a/market-maker/package.json b/market-maker/package.json index 560dec9..3677c95 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -37,7 +37,7 @@ "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#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts", "js-yaml": "^4.1.0", "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts", "pino": "^10.3.1", diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index c3b8829..a92e9dc 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#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 @@ -453,6 +453,10 @@ 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} @@ -1542,8 +1546,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} version: 1.0.0 engines: {node: 24.x} @@ -1624,8 +1628,8 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8} + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec} version: 1.0.0 hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: @@ -1909,10 +1913,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3477,6 +3477,8 @@ 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': @@ -4564,13 +4566,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/multicall3': https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@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/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4844,7 +4846,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#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 @@ -4854,8 +4856,8 @@ snapshots: '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 - hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -4998,7 +5000,7 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8: {} + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec: {} hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: {} @@ -5274,8 +5276,6 @@ 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: {} diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 006e799..4627a3d 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -64,50 +64,70 @@ export class FuturesVenueEvents implements VenueEvents { export function decodeEvent(log: FuturesLog): VenueEvent | null { switch (log.eventName) { case "OrderCreated": { - const { orderId, participant, pricePerDay, deliveryAt, isBuy } = log.args; + const { orderId, participant, price, quantity, deliveryAt } = log.args; if ( !orderId || !participant || - pricePerDay === undefined || - deliveryAt === undefined || - isBuy === undefined + price === undefined || + quantity === undefined || + deliveryAt === undefined ) return null; + const absQty = quantity < 0n ? -quantity : quantity; + if (absQty === 0n) return null; return { type: "order-created", orderId, participant, - price: pricePerDay, - side: isBuy ? "buy" : "sell", - size: 1n, // futures orders are single-contract per OrderCreated event + price, + side: quantity > 0n ? "buy" : "sell", + size: absQty, instrumentId: futuresInstrumentId(deliveryAt), deliveryDate: deliveryAt, }; } - case "OrderClosed": { - const { orderId } = log.args; - if (!orderId) return null; - // OrderClosed carries neither participant nor deliveryAt; per-expiry - // own-order caches resolve ownership + routing by cache membership. + case "OrderUpdated": { + const { orderId, participant, newQuantity } = log.args; + if (!orderId || !participant || newQuantity === undefined) return null; + if (newQuantity === 0n) { + return { type: "order-cancelled", orderId, participant }; + } + const absQty = newQuantity < 0n ? -newQuantity : newQuantity; return { - type: "order-cancelled", + type: "order-updated", orderId, + participant, + newSize: absQty, }; } - case "LotCreated": { - const { seller, deliveryAt } = log.args; - if (!seller || deliveryAt === undefined) return null; + case "OrderCancelled": { + const { orderId } = log.args; + if (!orderId) return null; + return { type: "order-cancelled", orderId }; + } + case "OrderMatched": { + const { maker, taker, deliveryAt } = log.args; + if (!maker || !taker || deliveryAt === undefined) return null; + // Broadcast position-changed for both sides; inventory resyncs via getUserPosition. return { type: "position-changed", - participant: seller, + participant: maker, instrumentId: futuresInstrumentId(deliveryAt), }; } - case "LotClosed": + case "PositionLiquidated": + case "PositionSettled": { + const { user, deliveryAt } = log.args as { + user?: `0x${string}`; + deliveryAt?: bigint; + }; + if (!user || deliveryAt === undefined) return null; return { type: "position-changed", - participant: "0x0" as `0x${string}`, + participant: user, + instrumentId: futuresInstrumentId(deliveryAt), }; + } default: return null; } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 6c214be..22b4096 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -59,68 +59,19 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } async getPosition(): Promise { - // Per-expiry net position, computed client-side. The engine's - // `getNetPositionDelta` is portfolio-wide (sums all expiries), so we walk - // this expiry's positions instead. Each position is a single matched unit - // (qty=1): buyer is long (+1), seller is short (-1). - const owner = this.venue.wallet.account.address.toLowerCase(); - const positionIds = await this.venue.publicClient.readContract({ + const pos = await this.venue.publicClient.readContract({ address: this.venue.address, abi: FuturesAbi, - functionName: "getPositionsByParticipantDeliveryDate", + functionName: "getUserPosition", args: [this.venue.wallet.account.address, this.deliveryDate], }); - - if (positionIds.length === 0) { + const netQuantity = pos.netQuantity; + if (netQuantity === 0n) { return { netQuantity: 0n, entryPrice: await this.venue.getRawMarketPrice() }; } - - const batchSize = this.venue.readBatchSize; - const positions: { - seller: string; - buyer: string; - sellPricePerDay: bigint; - buyPricePerDay: bigint; - }[] = []; - for (let i = 0; i < positionIds.length; i += batchSize) { - const chunk = positionIds.slice(i, i + batchSize); - const results = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: chunk.map((id) => ({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "getPositionById" as const, - args: [id] as const, - })), - }); - positions.push( - ...(results as { - seller: string; - buyer: string; - sellPricePerDay: bigint; - buyPricePerDay: bigint; - }[]), - ); - } - - let net = 0n; - let entrySum = 0n; - let entryCount = 0n; - for (const p of positions) { - if (p.buyer.toLowerCase() === owner) { - net += 1n; - entrySum += p.buyPricePerDay; - entryCount += 1n; - } - if (p.seller.toLowerCase() === owner) { - net -= 1n; - entrySum += p.sellPricePerDay; - entryCount += 1n; - } - } - const entryPrice = - entryCount > 0n ? entrySum / entryCount : await this.venue.getRawMarketPrice(); - return { netQuantity: net, entryPrice }; + const absNet = netQuantity < 0n ? -netQuantity : netQuantity; + const absEntry = pos.netEntryValue < 0n ? -pos.netEntryValue : pos.netEntryValue; + return { netQuantity, entryPrice: absEntry / absNet }; } async getContext(): Promise { @@ -133,33 +84,29 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } encodeCreate(intent: OrderIntent): `0x${string}` { - const qty = Number(intent.size); - if (qty <= 0 || qty > 127) { - throw new Error(`futures: order size ${qty} must be in (0, 127]`); + const qty = intent.size; + if (qty <= 0n) { + throw new Error(`futures: order size ${qty} must be > 0`); } - const signed = (intent.side === "buy" ? qty : -qty) as number & { - readonly __int8__: true; - }; + const signed = intent.side === "buy" ? qty : -qty; return encodeFunctionData({ abi: FuturesAbi, functionName: "createOrder", - args: [intent.price, this.deliveryDate, "", signed], + args: [intent.price, this.deliveryDate, signed], }); } encodeCancel(intent: CancelIntent): `0x${string}` { return encodeFunctionData({ abi: FuturesAbi, - functionName: "closeOrder", + functionName: "cancelOrder", args: [intent.orderId], }); } /** - * Cost units = qty. A futures `createOrder(…, int8 qty)` does one unit of - * work per contract, so its gas scales with qty (a qty=1 create ≈ one - * cancel). This lets the shared TxCoordinator budget futures batches by total - * qty rather than call count — the same weighting `chunkCalls` uses below. + * Cost units = qty. A futures create does work proportional to matched / + * resting contracts, so gas scales with qty (a qty=1 create ≈ one cancel). */ createCallWeight(intent: OrderIntent): number { return Number(intent.size); diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 5d0ad9a..60fcbf3 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -18,7 +18,7 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; * we read all of the wallet's orders and keep only those matching this * instrument's `deliveryDate`: * - * 1. `bootstrap()` reads `getOrderIds(wallet)` + `getOrderById(id)` and + * 1. `bootstrap()` reads `getUserOrders(wallet)` + `getOrder(id)` and * caches the orders whose `deliveryAt === deliveryDate`. * 2. `subscribe()` listens to venue events. `order-created` is filtered by * participant AND instrumentId (which encodes the expiry). `order-cancelled` @@ -74,7 +74,7 @@ export class FuturesOwnOrders implements OwnOrderSource { const orderIds = await this.venue.publicClient.readContract({ address: this.venue.address, abi: FuturesAbi, - functionName: "getOrderIds", + functionName: "getUserOrders", args: [owner], }); @@ -87,7 +87,7 @@ export class FuturesOwnOrders implements OwnOrderSource { const allCalls = orderIds.map((id) => ({ address: this.venue.address, abi: FuturesAbi, - functionName: "getOrderById" as const, + functionName: "getOrder" as const, args: [id] as const, })); @@ -105,18 +105,20 @@ export class FuturesOwnOrders implements OwnOrderSource { for (let i = 0; i < orderIds.length; i++) { const o = allOrders[i] as { participant: string; - pricePerDay: bigint; + price: bigint; + quantity: bigint; deliveryAt: bigint; - isBuy: boolean; }; if (!o.participant || o.participant === ZERO_ADDRESS) continue; // Keep only orders belonging to this expiry. if (o.deliveryAt !== this.deliveryDate) continue; + if (o.quantity === 0n) continue; + const absQty = o.quantity < 0n ? -o.quantity : o.quantity; this.cache.set(orderIds[i], { orderId: orderIds[i], - price: o.pricePerDay, - side: o.isBuy ? "buy" : "sell", - size: 1n, + price: o.price, + side: o.quantity > 0n ? "buy" : "sell", + size: absQty, instrumentId: this.instrumentId, }); } From 5153249c28bb6256fdf5b9512181e37e8fa4a1a5 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 15:57:28 +0200 Subject: [PATCH 089/180] fix(keeper): restore futures-marketplace-abi pnpm patch abi/package.json is not shipped in the futures repo (compile wipes it); re-enable the patch that injects the package manifest for consumers. Co-authored-by: Cursor --- keeper/pnpm-lock.yaml | 5 +++-- keeper/pnpm-workspace.yaml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 5c1b891..c88b00f 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: patchedDependencies: collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d + futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a importers: @@ -23,7 +24,7 @@ importers: version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -363,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index ec4c4f0..7064f91 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -3,3 +3,4 @@ blockExoticSubdeps: false patchedDependencies: collateral-margin-abi: patches/collateral-margin-abi@.patch derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch + futures-marketplace-abi: patches/futures-marketplace-abi@.patch From f6a05d379be30e5705a5b6d12ee78d0dfb2552fe Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 17:05:16 +0200 Subject: [PATCH 090/180] fix(ci,mm): align Futures 3.0 ABI pin and createOrder args Integration tests were compiling an old futures-marketplace checkout (4-arg createOrder) while callers used the 3.0 API. Point the keeper workflow at the aggregate-positions tip and drop the leftover destURL arg from MM gas estimation. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 4 ++- keeper/pnpm-lock.yaml | 8 ++--- market-maker/pnpm-lock.yaml | 34 +++++++++---------- .../src/adapters/futures/instrument.ts | 8 ++--- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 9597f96..64d8ee5 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -74,7 +74,9 @@ jobs: uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - ref: 5e28bb346b125d90eb138370a8a78c69487d1c26 + # Futures 3.0 aggregate positions (PR Lumerin-protocol/futures-marketplace#215). + # After that merges to dev, retarget this pin to the merge commit / dev tip. + ref: d5cf8b9154276efdbe583c5be4c81eb0eedbaadc path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index c88b00f..fc82fa4 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,8 +166,8 @@ 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/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-YGxdbAtW7wJrarKb4VO7aje5va1P9PgtKZlZUddujcByikRkOlYoHsfy24C5U/bbQE++7HSHTxbX9osA+zUVbQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc} version: 0.0.0 help-me@5.0.0: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index a92e9dc..c3b2e5c 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#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 @@ -453,10 +453,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} @@ -1546,8 +1542,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc} version: 1.0.0 engines: {node: 24.x} @@ -1913,6 +1909,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: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2508,8 +2508,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.11: - resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} engines: {node: '>=8.3.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3477,8 +3477,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': @@ -4566,13 +4564,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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 '@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/a7f89b8fd167655d1157568230bc6779ce37d512 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' @@ -4846,7 +4844,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#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 @@ -4857,7 +4855,7 @@ snapshots: '@safe-global/types-kit': 2.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - 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 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -4968,7 +4966,7 @@ snapshots: tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 - ws: 7.5.11 + ws: 7.5.13 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -5276,6 +5274,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: {} @@ -5836,7 +5836,7 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.11: {} + ws@7.5.13: {} ws@8.18.0: {} diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 22b4096..670ad16 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -225,12 +225,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: FuturesAbi, functionName: "createOrder", - args: [ - 1_000_000n, - this.deliveryDate, - "", - 1 as number & { readonly __int8__: true }, - ], + // Futures 3.0: createOrder(price, deliveryAt, signedQuantity) + args: [1_000_000n, this.deliveryDate, 1n], account, }); } catch { From 23589b289f45f2ddb51a49a78e53fd1a8c475f98 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 17:23:48 +0200 Subject: [PATCH 091/180] fix(keeper): pin futures ABI to commit that ships package.json Tip d5cf8b91 dropped abi/package.json so CI typecheck could not resolve futures-marketplace-abi/Futures.ts. Pin the npm dep to 6215556a (same 3.0 ABI, still has package.json); integration still compiles d5cf8b91 via the workflow FUTURES_REPO checkout. Co-authored-by: Cursor --- keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 9a93177..07faaf5 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#6215556a3419e2ff1e5b19567fc5f2b9cbeb080c&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index fc82fa4..fed2e7b 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#6215556a3419e2ff1e5b19567fc5f2b9cbeb080c&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,8 +166,8 @@ 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/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-YGxdbAtW7wJrarKb4VO7aje5va1P9PgtKZlZUddujcByikRkOlYoHsfy24C5U/bbQE++7HSHTxbX9osA+zUVbQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} version: 0.0.0 help-me@5.0.0: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} From f952ac6a4d4b765e17f11d25f000f82a8d2db456 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 17:55:29 +0200 Subject: [PATCH 092/180] refactor(futures): align PME/keeper/MM names with Futures expirationAt API Rename getFutures* margin views to getOrderMargin/getUnrealizedPnl to match perps, and switch deliveryAt terminology to expirationAt across PME, keeper, and market-maker consumers. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 4 +- contracts/contracts/interfaces/IFutures.sol | 12 +- contracts/contracts/mocks/FuturesMock.sol | 10 +- .../tests/crossMarginIntegration.test.ts | 4 +- docs/liquidation-orchestration.md | 2 +- docs/points-system-design.md | 2 +- keeper/README.md | 4 +- keeper/scripts/audit-indexer-sync.ts | 10 +- keeper/scripts/debug-delivery-bootstrap.ts | 18 +-- keeper/src/config.ts | 8 +- keeper/src/delivery/coordinator.ts | 132 +++++++++--------- keeper/src/discovery/tracker.ts | 2 +- keeper/src/index.ts | 6 +- keeper/src/predict/snapshot.ts | 24 ++-- keeper/src/predict/solve.ts | 16 +-- keeper/src/predict/types.ts | 10 +- keeper/src/runtime/outdatedOrderSweeper.ts | 16 +-- keeper/src/venues/futures.ts | 38 ++--- keeper/src/venues/types.ts | 4 +- keeper/tests/delivery/coordinator.test.ts | 30 ++-- keeper/tests/integration/deployStack.ts | 10 +- keeper/tests/integration/helpers.ts | 20 +-- .../integration/keeper.integration.test.ts | 54 +++---- keeper/tests/integration/scenarios.ts | 54 +++---- keeper/tests/predict/coordinator.test.ts | 4 +- .../tests/predict/coordinatorAlerts.test.ts | 4 +- keeper/tests/predict/mm.test.ts | 18 +-- keeper/tests/predict/snapshot.test.ts | 22 +-- keeper/tests/predict/solve.test.ts | 2 +- keeper/tests/predict/solveTarget.test.ts | 14 +- .../runtime/outdatedOrderSweeper.test.ts | 36 ++--- keeper/tests/venues/futures-marketid.test.ts | 10 +- keeper/tests/venues/futures.test.ts | 22 +-- keeper/tests/venues/reduceToTarget.test.ts | 16 +-- market-maker/schemas/futures.json | 2 +- market-maker/schemas/portfolio.json | 2 +- market-maker/src/adapters/futures/events.ts | 26 ++-- .../src/adapters/futures/instrument.ts | 24 ++-- .../src/adapters/futures/ownOrders.ts | 18 +-- market-maker/src/adapters/futures/venue.ts | 30 ++-- market-maker/src/apps/futures/config.ts | 2 +- market-maker/src/core/adapter.ts | 4 +- .../src/core/pricing/reservationPrice.ts | 10 +- market-maker/src/core/quoter.ts | 2 +- .../core/pricing/reservationPrice.test.ts | 8 +- 45 files changed, 383 insertions(+), 383 deletions(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 2e4c2ca..6318d89 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -172,8 +172,8 @@ contract PortfolioMarginEngine is uint256 futuresOrderMargin = 0; uint256 futuresUnrealizedLoss = 0; if (address(futures) != address(0)) { - futuresOrderMargin = futures.getFuturesOrderMargin(user); - int256 futuresPnl = futures.getFuturesUnrealizedPnl(user); + futuresOrderMargin = futures.getOrderMargin(user); + int256 futuresPnl = futures.getUnrealizedPnl(user); futuresUnrealizedLoss = futuresPnl < 0 ? uint256(-futuresPnl) : 0; } diff --git a/contracts/contracts/interfaces/IFutures.sol b/contracts/contracts/interfaces/IFutures.sol index 8589180..3b491ab 100644 --- a/contracts/contracts/interfaces/IFutures.sol +++ b/contracts/contracts/interfaces/IFutures.sol @@ -6,25 +6,25 @@ pragma solidity ^0.8.20; /// incorporate hashrate futures into cross-product margin calculation. /// /// Delta convention (WAD = 1e18): -/// A long position of 1 contract over D delivery days contributes -/// delta = D * WAD (token-decimals of PnL per token-decimal move in -/// the daily hashrate price), matching the scaling used for perp delta. +/// Whole-contract futures 3.0: each contract of net quantity contributes +/// ±1 WAD of delta (token-decimals of PnL per token-decimal move in +/// the hashrate price), matching the scaling used for perp delta. interface IFutures { /// @notice Net linear delta of all *active positions* (WAD-scaled, signed). /// Positive = net long exposure; negative = net short. /// Only counts matched positions, not resting orders (those are - /// captured via `getFuturesOrderMargin`). + /// captured via `getOrderMargin`). function getNetPositionDelta(address participant) external view returns (int256); /// @notice Minimum margin locked by resting orders (token decimals). /// Mirrors `getOrderMargin` in IHashPowerPerpsDEX: it is the /// maintenance-margin-less-unrealized-PnL component for unmatched /// orders, clamped to zero (orders can't produce a net credit). - function getFuturesOrderMargin(address participant) external view returns (uint256); + function getOrderMargin(address participant) external view returns (uint256); /// @notice Aggregate unrealized PnL across active positions (token decimals). /// Positive = mark-to-market gain; negative = mark-to-market loss. - function getFuturesUnrealizedPnl(address participant) external view returns (int256); + function getUnrealizedPnl(address participant) external view returns (int256); /// @notice Current oracle-derived hashrate spot price (token decimals). /// Used as a fallback price source when no perps DEX is registered. diff --git a/contracts/contracts/mocks/FuturesMock.sol b/contracts/contracts/mocks/FuturesMock.sol index e9ae39c..bba7f76 100644 --- a/contracts/contracts/mocks/FuturesMock.sol +++ b/contracts/contracts/mocks/FuturesMock.sol @@ -6,7 +6,7 @@ import { IFutures } from "../interfaces/IFutures.sol"; /// @title FuturesMock — Minimal mock of the Futures contract for PME tests /// @notice Direct analogue of `PerpsDEXMock`: lets tests pin the per-user /// IFutures view outputs (`getNetPositionDelta`, -/// `getFuturesOrderMargin`, `getFuturesUnrealizedPnl`) and the +/// `getOrderMargin`, `getUnrealizedPnl`) and the /// shared market price. All views default to zero so a fresh mock /// is a no-op contributor to portfolio margin. contract FuturesMock is IFutures { @@ -35,19 +35,19 @@ contract FuturesMock is IFutures { return _netDelta[user]; } - function setFuturesOrderMargin(address user, uint256 om) external { + function setOrderMargin(address user, uint256 om) external { _orderMargin[user] = om; } - function getFuturesOrderMargin(address user) external view returns (uint256) { + function getOrderMargin(address user) external view returns (uint256) { return _orderMargin[user]; } - function setFuturesUnrealizedPnl(address user, int256 pnl) external { + function setUnrealizedPnl(address user, int256 pnl) external { _unrealizedPnl[user] = pnl; } - function getFuturesUnrealizedPnl(address user) external view returns (int256) { + function getUnrealizedPnl(address user) external view returns (int256) { return _unrealizedPnl[user]; } } diff --git a/contracts/tests/crossMarginIntegration.test.ts b/contracts/tests/crossMarginIntegration.test.ts index 399cce9..14872e0 100644 --- a/contracts/tests/crossMarginIntegration.test.ts +++ b/contracts/tests/crossMarginIntegration.test.ts @@ -282,8 +282,8 @@ describe("Cross-Margin Integration", () => { const futuresLoss = -2_500_000_000n; const perpsOrder = 1_500_000_000n; - await futuresMock.write.setFuturesOrderMargin([aliceAddr, futuresOrder]); - await futuresMock.write.setFuturesUnrealizedPnl([aliceAddr, futuresLoss]); + await futuresMock.write.setOrderMargin([aliceAddr, futuresOrder]); + await futuresMock.write.setUnrealizedPnl([aliceAddr, futuresLoss]); await perpsMock.write.setOrderMargin([aliceAddr, perpsOrder]); const im = await pme.read.computePortfolioIM([aliceAddr]); diff --git a/docs/liquidation-orchestration.md b/docs/liquidation-orchestration.md index e59bf6b..fa9cd96 100644 --- a/docs/liquidation-orchestration.md +++ b/docs/liquidation-orchestration.md @@ -225,7 +225,7 @@ though the *final* state is perfectly in-band. Prerequisite (already satisfied): `PortfolioMarginEngine.computePortfolioIM/MM` spans **all three legs** — it holds an `IFutures` ref and folds in -`getFuturesOrderMargin`, `getFuturesUnrealizedPnl`, `getNetPositionDelta` +`getOrderMargin`, `getUnrealizedPnl`, `getNetPositionDelta` alongside perps + options. So a single on-chain portfolio-margin check that covers both venues already exists. diff --git a/docs/points-system-design.md b/docs/points-system-design.md index 9abaf47..33700f3 100644 --- a/docs/points-system-design.md +++ b/docs/points-system-design.md @@ -43,7 +43,7 @@ Explicitly **not** rewarded: Both venues distinguish maker and taker on-chain: - perps: the `OrderMatched` event carries `maker`, `taker`, and separate `makerFee` / `takerFee`. -- futures (3.0): `OrderMatched` carries `maker`, `taker`, `makerFee` / `takerFee` (same shape as perps, plus `deliveryAt`). +- futures (3.0): `OrderMatched` carries `maker`, `taker`, `makerFee` / `takerFee` (same shape as perps, plus `expirationAt`). This lets the points hook (Section 5) apply different weights to each side without any off-chain inference. diff --git a/keeper/README.md b/keeper/README.md index ecba36e..029b7d0 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -96,7 +96,7 @@ src/ venues/ types.ts # Venue interface (multi-market aware: perps, futures, options) perps.ts # Perps adapter (HashPowerPerpsDEX) - futures.ts # Futures adapter (deliveryAt → marketId) + futures.ts # Futures adapter (expirationAt → marketId) coordinator/ queue.ts # mmSurplus-ordered cross-account priority queue planner.ts # Per-account orders → positions liquidation plan @@ -216,7 +216,7 @@ Suites cover: - `pme/health` — multicall batching + `imUtilization` precision - `venues/perps` — long/short PnL math, `PERPS_MARKET_ID` sentinel, position id -- `venues/futures` — buyer/seller PnL (one contract = 1 PH/s·day, duration-free), `deliveryAt` → marketId +- `venues/futures` — buyer/seller PnL (one contract = 1 PH/s·day, duration-free), `expirationAt` → marketId - `coordinator/queue` — BigInt-safe ordering, `upsert` re-ranking, snapshot semantics - `coordinator/planner` — orders-leg, position ranking, `OrdersStillOpen`-replay, bad-debt - `alert/notifier` — dedupe window, severity promotion, ordering, retry-on-failure diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts index e6145b4..34c8ccd 100644 --- a/keeper/scripts/audit-indexer-sync.ts +++ b/keeper/scripts/audit-indexer-sync.ts @@ -1,5 +1,5 @@ /** - * Audit indexer netQuantityAfter against on-chain getActiveDeliveryDates count. + * Audit indexer netQuantityAfter against on-chain getActiveExpirationDates count. * Finds the first block where indexer and chain diverge. * * Run: @@ -34,7 +34,7 @@ interface Trade { id: string; tradeQuantity: number; netQuantityAfter: number; - deliveryAt: string; + expirationAt: string; transactionHash: string; blockNumber: string; fills: TradeFill[]; @@ -51,7 +51,7 @@ async function fetchTrades(): Promise { id tradeQuantity netQuantityAfter - deliveryAt + expirationAt transactionHash blockNumber fills(where: { user: $user }) { @@ -77,7 +77,7 @@ async function getChainPositionCount(blockNumber: number): Promise { const ids = await client.readContract({ address: FUT, abi: FuturesAbi, - functionName: "getActiveDeliveryDates", + functionName: "getActiveExpirationDates", args: [USER as Address], blockNumber: BigInt(blockNumber), }); @@ -167,7 +167,7 @@ async function main() { console.log("First divergence at block %d:", firstMismatch.block); console.log(" tx: %s", firstMismatch.tx); console.log(" indexer netQuantityAfter (abs): %d", firstMismatch.indexer); - console.log(" chain getActiveDeliveryDates().length: %d", firstMismatch.chain); + console.log(" chain getActiveExpirationDates().length: %d", firstMismatch.chain); } else { console.log("No divergence detected — indexer and chain are in sync."); } diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index e53a2c8..7f81582 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -28,26 +28,26 @@ console.log("RPC:", RPC_URL.replace(ALCHEMY, "***")); console.log("FUTURES:", FUTURES); console.log("USERS:", USERS); -console.log("\n--- Stage 1: getActiveDeliveryDates via multicall ---"); +console.log("\n--- Stage 1: getActiveExpirationDates via multicall ---"); const dateLists = await client.multicall({ contracts: USERS.map((u) => ({ address: FUTURES, abi: FuturesAbi, - functionName: "getActiveDeliveryDates" as const, + functionName: "getActiveExpirationDates" as const, args: [u] as const, })), allowFailure: false, }); -type Pair = { user: Address; deliveryAt: bigint }; +type Pair = { user: Address; expirationAt: bigint }; const pairs: Pair[] = []; for (let i = 0; i < USERS.length; i++) { const user = USERS[i]!; const dates = dateLists[i] as readonly bigint[]; console.log(` ${user} → ${dates.length} expiries`); - for (const deliveryAt of dates) { - console.log(` ${deliveryAt}`); - pairs.push({ user, deliveryAt }); + for (const expirationAt of dates) { + console.log(` ${expirationAt}`); + pairs.push({ user, expirationAt }); } } @@ -62,7 +62,7 @@ const positions = await client.multicall({ address: FUTURES, abi: FuturesAbi, functionName: "getUserPosition" as const, - args: [p.user, p.deliveryAt] as const, + args: [p.user, p.expirationAt] as const, })), allowFailure: false, }); @@ -78,10 +78,10 @@ for (let i = 0; i < pairs.length; i++) { const pos = positions[i] as { netQuantity: bigint; netEntryValue: bigint }; if (pos.netQuantity === 0n) continue; live++; - const due = block.timestamp >= pair.deliveryAt; + const due = block.timestamp >= pair.expirationAt; if (due) pastDue++; console.log( - ` ${pair.user} @ ${pair.deliveryAt}: qty=${pos.netQuantity} entryValue=${pos.netEntryValue}` + + ` ${pair.user} @ ${pair.expirationAt}: qty=${pos.netQuantity} entryValue=${pos.netEntryValue}` + (due ? " PAST_DUE" : ""), ); } diff --git a/keeper/src/config.ts b/keeper/src/config.ts index dca8e94..71eb100 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -70,7 +70,7 @@ export interface Config { /** * Max futures expiry legs closed per `liquidatePositions` tx (gas-bounded * chunking). `reduceToTarget` sends ONE worst-first chunk of at most this - * many `(deliveryAt, closeQty)` pairs; the planner loop re-invokes it + * many `(expirationAt, closeQty)` pairs; the planner loop re-invokes it * (re-snapshotting each time) until the account is healthy. Keep ≤ ~50 so * a full chunk stays well under Base's block gas limit. */ @@ -189,7 +189,7 @@ export interface Config { /** * Opt-in: when true, the keeper permissionlessly calls * `settlePosition(positionId)` on every active futures position the moment - * its `deliveryAt` (maturity) is reached, cash-settling it at the oracle + * its `expirationAt` (maturity) is reached, cash-settling it at the oracle * mark. Defaults to `false` so a stock keeper deployment doesn't start * settling positions unless explicitly enabled. * @@ -206,8 +206,8 @@ export interface Config { */ sweepIntervalMs: number; /** - * Delay after `position.deliveryAt` before attempting `settlePosition`. - * Adds a small cushion so the on-chain `block.timestamp >= deliveryAt` + * Delay after `position.expirationAt` before attempting `settlePosition`. + * Adds a small cushion so the on-chain `block.timestamp >= expirationAt` * guard is satisfied even when local and miner clocks drift slightly. */ settleDelayMs: number; diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index 2030f84..ceaa3ae 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -16,8 +16,8 @@ import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import { formatGasCost } from "../tx/gasCost.ts"; /** - * Optional keeper module that calls `Futures.settlePosition(user, deliveryAt)` - * on every active futures aggregate the moment its `deliveryAt` (maturity) is + * Optional keeper module that calls `Futures.settlePosition(user, expirationAt)` + * on every active futures aggregate the moment its `expirationAt` (maturity) is * reached. Settlement pins the expiry price (lazily on first settle) and * cash-settles that user's unilateral PnL through the insurance fund. * @@ -26,12 +26,12 @@ import { formatGasCost } from "../tx/gasCost.ts"; * Hot path is event-driven: * * OrderMatched ─▶ re-index maker + taker active expiries - * PositionSettled ─▶ drop that (user, deliveryAt) from the index + * PositionSettled ─▶ drop that (user, expirationAt) from the index * timer fires ─▶ settle matured tracked aggregates * * Cold-start safety net: * - * bootstrapFromUsers(addrs) ─▶ getActiveDeliveryDates + getUserPosition + * bootstrapFromUsers(addrs) ─▶ getActiveExpirationDates + getUserPosition * backfill(fromBlock) ─▶ replay OrderMatched / PositionSettled * sweep() ─▶ periodic settle of past-due tracked rows */ @@ -203,7 +203,7 @@ export class DeliveryCoordinator { } const pastDue = this.countPastDuePositions(); - const nextDueAt = this.findEarliestDeliveryAt(); + const nextDueAt = this.findEarliestExpirationAt(); this.logger.info( { users: users.length, @@ -225,16 +225,16 @@ export class DeliveryCoordinator { const nowSec = BigInt(Math.floor(Date.now() / 1000)); let n = 0; for (const pos of this.tracked.values()) { - if (nowSec >= pos.deliveryAt) n++; + if (nowSec >= pos.expirationAt) n++; } return n; } - private findEarliestDeliveryAt(): bigint | undefined { + private findEarliestExpirationAt(): bigint | undefined { let earliest: bigint | undefined; for (const pos of this.tracked.values()) { - if (earliest === undefined || pos.deliveryAt < earliest) - earliest = pos.deliveryAt; + if (earliest === undefined || pos.expirationAt < earliest) + earliest = pos.expirationAt; } return earliest; } @@ -255,55 +255,55 @@ export class DeliveryCoordinator { } private async indexUserPositionsInternal(user: Address): Promise { - let deliveryAts: readonly bigint[]; + let expirationAts: readonly bigint[]; try { - deliveryAts = (await this.chain.publicClient.readContract({ + expirationAts = (await this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getActiveDeliveryDates", + functionName: "getActiveExpirationDates", args: [user], })) as readonly bigint[]; } catch (err) { - this.logger.error({ err, user }, "delivery: getActiveDeliveryDates failed"); + this.logger.error({ err, user }, "delivery: getActiveExpirationDates failed"); return 0; } - if (deliveryAts.length === 0) return 0; + if (expirationAts.length === 0) return 0; const positions = (await this.chain.publicClient.multicall({ - contracts: deliveryAts.map((deliveryAt) => ({ + contracts: expirationAts.map((expirationAt) => ({ address: this.config.futures.address, abi: FuturesAbi, functionName: "getUserPosition" as const, - args: [user, deliveryAt] as const, + args: [user, expirationAt] as const, })), allowFailure: false, })) as readonly { netQuantity: bigint; netEntryValue: bigint }[]; let added = 0; - for (let i = 0; i < deliveryAts.length; i++) { - const deliveryAt = deliveryAts[i]!; + for (let i = 0; i < expirationAts.length; i++) { + const expirationAt = expirationAts[i]!; const pos = positions[i]; if (pos === undefined || pos.netQuantity === 0n) continue; - if (this.upsertTracked(user, deliveryAt)) added++; + if (this.upsertTracked(user, expirationAt)) added++; } return added; } /** Insert or refresh a tracked aggregate. Returns true if newly added. */ - private upsertTracked(user: Address, deliveryAt: bigint): boolean { - const key = trackKey(user, deliveryAt); + private upsertTracked(user: Address, expirationAt: bigint): boolean { + const key = trackKey(user, expirationAt); if (this.tracked.has(key)) return false; const tracked: TrackedPosition = { user: getAddress(user), - deliveryAt, + expirationAt, }; this.tracked.set(key, tracked); this.scheduleTimer(tracked); return true; } - private dropTracked(user: Address, deliveryAt: bigint): void { - const key = trackKey(user, deliveryAt); + private dropTracked(user: Address, expirationAt: bigint): void { + const key = trackKey(user, expirationAt); this.tracked.delete(key); const t = this.timers.get(key); if (t !== undefined) { @@ -318,8 +318,8 @@ export class DeliveryCoordinator { const candidates: TrackedPosition[] = []; for (const pos of this.tracked.values()) { - if (this.inflight.has(trackKey(pos.user, pos.deliveryAt))) continue; - if (nowSec < pos.deliveryAt) continue; + if (this.inflight.has(trackKey(pos.user, pos.expirationAt))) continue; + if (nowSec < pos.expirationAt) continue; candidates.push(pos); } @@ -354,20 +354,20 @@ export class DeliveryCoordinator { } /** Public for tests. */ - has(user: Address, deliveryAt: bigint): boolean { - return this.tracked.has(trackKey(user, deliveryAt)); + has(user: Address, expirationAt: bigint): boolean { + return this.tracked.has(trackKey(user, expirationAt)); } - async settle(user: Address, deliveryAt: bigint): Promise { - await this.settleBatch([{ user: getAddress(user), deliveryAt }]); + async settle(user: Address, expirationAt: bigint): Promise { + await this.settleBatch([{ user: getAddress(user), expirationAt }]); } async settleBatch(positions: readonly TrackedPosition[]): Promise { const fresh: TrackedPosition[] = []; for (const pos of positions) { - const key = trackKey(pos.user, pos.deliveryAt); + const key = trackKey(pos.user, pos.expirationAt); if (this.inflight.has(key)) continue; - fresh.push({ user: getAddress(pos.user), deliveryAt: pos.deliveryAt }); + fresh.push({ user: getAddress(pos.user), expirationAt: pos.expirationAt }); this.inflight.add(key); } if (fresh.length === 0) return; @@ -377,7 +377,7 @@ export class DeliveryCoordinator { await next; } finally { for (const pos of fresh) { - this.inflight.delete(trackKey(pos.user, pos.deliveryAt)); + this.inflight.delete(trackKey(pos.user, pos.expirationAt)); } } } @@ -392,7 +392,7 @@ export class DeliveryCoordinator { address: this.config.futures.address, abi: FuturesAbi, functionName: "settlePosition", - args: [pos.user, pos.deliveryAt], + args: [pos.user, pos.expirationAt], account: this.chain.account, } as unknown as SimParams), ), @@ -410,12 +410,12 @@ export class DeliveryCoordinator { if (decoded !== undefined) { this.logRecoverableRevert(decoded, pos); if (decoded === "PositionNotExists") { - this.dropTracked(pos.user, pos.deliveryAt); + this.dropTracked(pos.user, pos.expirationAt); } continue; } this.logger.error( - { err: r.reason, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + { err: r.reason, user: pos.user, expirationAt: pos.expirationAt.toString() }, "delivery: simulate failed with non-recoverable error — skipping from batch", ); } @@ -433,7 +433,7 @@ export class DeliveryCoordinator { { batchSize: settleable.length }, "[dryRun] would call Futures.multicall(settlePosition × N)", ); - for (const pos of settleable) this.dropTracked(pos.user, pos.deliveryAt); + for (const pos of settleable) this.dropTracked(pos.user, pos.expirationAt); return; } @@ -444,13 +444,13 @@ export class DeliveryCoordinator { const data = encodeFunctionData({ abi: FuturesAbi, functionName: "settlePosition", - args: [pos.user, pos.deliveryAt], + args: [pos.user, pos.expirationAt], }); calldatas.push(data); encodable.push(pos); } catch (err) { this.logger.error( - { err, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + { err, user: pos.user, expirationAt: pos.expirationAt.toString() }, "delivery: encodeFunctionData threw — dropping malformed entry from batch", ); } @@ -489,7 +489,7 @@ export class DeliveryCoordinator { await this.attemptSettle(pos); } catch (innerErr) { this.logger.error( - { err: innerErr, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + { err: innerErr, user: pos.user, expirationAt: pos.expirationAt.toString() }, "delivery: per-position fallback failed — leaving for next sweep", ); } @@ -512,12 +512,12 @@ export class DeliveryCoordinator { ); for (const pos of encodable) { - this.dropTracked(pos.user, pos.deliveryAt); + this.dropTracked(pos.user, pos.expirationAt); } } private async attemptSettle(pos: TrackedPosition): Promise { - const args = [pos.user, pos.deliveryAt] as const; + const args = [pos.user, pos.expirationAt] as const; type SimParams = Parameters< typeof this.chain.publicClient.simulateContract @@ -540,7 +540,7 @@ export class DeliveryCoordinator { if (decoded !== undefined) { this.logRecoverableRevert(decoded, pos); if (decoded === "PositionNotExists") { - this.dropTracked(pos.user, pos.deliveryAt); + this.dropTracked(pos.user, pos.expirationAt); } return; } @@ -549,10 +549,10 @@ export class DeliveryCoordinator { if (this.config.keeper.dryRun) { this.logger.info( - { user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + { user: pos.user, expirationAt: pos.expirationAt.toString() }, "[dryRun] would call settlePosition", ); - this.dropTracked(pos.user, pos.deliveryAt); + this.dropTracked(pos.user, pos.expirationAt); return; } @@ -569,21 +569,21 @@ export class DeliveryCoordinator { this.logger.info( { user: pos.user, - deliveryAt: pos.deliveryAt.toString(), + expirationAt: pos.expirationAt.toString(), hash, blockNumber: receipt.blockNumber.toString(), ...formatGasCost(receipt, this.ethUsdFeed), }, "delivery: settlePosition confirmed", ); - this.dropTracked(pos.user, pos.deliveryAt); + this.dropTracked(pos.user, pos.expirationAt); } private onOrderMatched(logs: readonly Log[]): void { type Args = { maker?: Address; taker?: Address; - deliveryAt?: bigint; + expirationAt?: bigint; makerNetQtyAfter?: bigint; takerNetQtyAfter?: bigint; }; @@ -592,16 +592,16 @@ export class DeliveryCoordinator { const args = (raw as unknown as { args?: Args }).args; if (args === undefined) continue; // Fast path: if post-match qty is available, upsert/drop without RPC. - if (args.deliveryAt !== undefined) { + if (args.expirationAt !== undefined) { if (args.maker !== undefined && args.makerNetQtyAfter !== undefined) { - if (args.makerNetQtyAfter === 0n) this.dropTracked(args.maker, args.deliveryAt); - else this.upsertTracked(args.maker, args.deliveryAt); + if (args.makerNetQtyAfter === 0n) this.dropTracked(args.maker, args.expirationAt); + else this.upsertTracked(args.maker, args.expirationAt); } else if (args.maker !== undefined) { users.add(args.maker); } if (args.taker !== undefined && args.takerNetQtyAfter !== undefined) { - if (args.takerNetQtyAfter === 0n) this.dropTracked(args.taker, args.deliveryAt); - else this.upsertTracked(args.taker, args.deliveryAt); + if (args.takerNetQtyAfter === 0n) this.dropTracked(args.taker, args.expirationAt); + else this.upsertTracked(args.taker, args.expirationAt); } else if (args.taker !== undefined) { users.add(args.taker); } @@ -616,35 +616,35 @@ export class DeliveryCoordinator { } private onPositionSettled(logs: readonly Log[]): void { - type Args = { user?: Address; deliveryAt?: bigint }; + type Args = { user?: Address; expirationAt?: bigint }; for (const raw of logs) { const args = (raw as unknown as { args?: Args }).args; - if (args?.user === undefined || args.deliveryAt === undefined) continue; - this.dropTracked(args.user, args.deliveryAt); + if (args?.user === undefined || args.expirationAt === undefined) continue; + this.dropTracked(args.user, args.expirationAt); } } private logRecoverableRevert(revert: RecoverableRevert, pos: TrackedPosition): void { if (revert === "PositionNotExists") { this.logger.info( - { user: pos.user, deliveryAt: pos.deliveryAt.toString(), revert }, + { user: pos.user, expirationAt: pos.expirationAt.toString(), revert }, "delivery: position already settled by someone else — dropping from index", ); return; } this.logger.debug( - { user: pos.user, deliveryAt: pos.deliveryAt.toString(), revert }, + { user: pos.user, expirationAt: pos.expirationAt.toString(), revert }, "delivery: settlePosition skipped (transient revert, will retry)", ); } private scheduleTimer(pos: TrackedPosition): void { - const key = trackKey(pos.user, pos.deliveryAt); + const key = trackKey(pos.user, pos.expirationAt); const existing = this.timers.get(key); if (existing !== undefined) clearTimeout(existing); const targetMs = - Number(pos.deliveryAt) * 1000 + this.config.delivery.settleDelayMs; + Number(pos.expirationAt) * 1000 + this.config.delivery.settleDelayMs; const delayMs = Math.max(0, targetMs - Date.now()); if (delayMs > MAX_TIMEOUT_MS) { return; @@ -652,7 +652,7 @@ export class DeliveryCoordinator { const timer = setTimeout(() => { void this.sweep().catch((err) => { this.logger.error( - { err, user: pos.user, deliveryAt: pos.deliveryAt.toString() }, + { err, user: pos.user, expirationAt: pos.expirationAt.toString() }, "delivery: timer-fired sweep threw", ); }); @@ -662,26 +662,26 @@ export class DeliveryCoordinator { } } -export function trackKey(user: Address, deliveryAt: bigint): string { - return `${getAddress(user).toLowerCase()}:${deliveryAt.toString()}`; +export function trackKey(user: Address, expirationAt: bigint): string { + return `${getAddress(user).toLowerCase()}:${expirationAt.toString()}`; } interface TrackedPosition { user: Address; - deliveryAt: bigint; + expirationAt: bigint; } const MAX_TIMEOUT_MS = 2_147_483_647; type RecoverableRevert = | "PositionNotExists" - | "PositionDeliveryNotStartedYet" + | "PositionExpirationNotStartedYet" | "OracleStale" | "InvalidOracle"; const RECOVERABLE_REVERTS = new Set([ "PositionNotExists", - "PositionDeliveryNotStartedYet", + "PositionExpirationNotStartedYet", "OracleStale", "InvalidOracle", ]); diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index 6f432fa..a186dc8 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -462,7 +462,7 @@ export class ParticipantTracker { /** * `OrderCreated(bytes32 indexed orderId, address indexed participant, - * uint256 price, int256 quantity, uint256 deliveryAt)`. + * uint256 price, int256 quantity, uint256 expirationAt)`. */ private onFuturesOrderCreated(logs: readonly Log[]): void { type Args = { orderId?: Hex; participant?: Address }; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 9c12e4a..1ed6ebd 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -141,7 +141,7 @@ async function main(): Promise { webhookIngester = new WebhookIngester(config, tracker, logger); } - // Optional: cash-settle futures positions at their maturity (`deliveryAt`) + // Optional: cash-settle futures positions at their maturity (`expirationAt`) // via the permissionless `Futures.settlePosition`. Off by default. Any keeper // signer can settle — no validator role required. See `delivery/coordinator.ts`. let deliveryCoordinator: DeliveryCoordinator | undefined; @@ -163,7 +163,7 @@ async function main(): Promise { const balanceMonitor = new BalanceMonitor(chain, config, logger); // Futures-specific maintenance: walks tracked participants and closes - // any of their orders past `deliveryAt` via the permissionless + // 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 @@ -270,7 +270,7 @@ async function main(): Promise { // its own positions). Their positions may pre-date // BACKFILL_FROM_BLOCK, in which case the tracker has no // record of them — but we know the address at boot, so the - // one extra `getActiveDeliveryDates` read is a free safety net. + // one extra `getActiveExpirationDates` read is a free safety net. // 2. The manual seed list (`DELIVERY_BOOTSTRAP_USERS`). Used to // recover a known-stuck user when the tracker hasn't found // them — typical when log backfill is failing on the diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 2a6b024..ace408e 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -56,7 +56,7 @@ export async function readMMParams( * function of price. Two RPC round-trips: * * 1. Bulk multicall: balance, perp position/orderMargin/funding, - * futures orderMargin/activeDeliveryDates. + * futures orderMargin/activeExpirationAts. * 2. Per-expiry multicall: hydrate each aggregate via `getUserPosition`. * * Round-trip 2 collapses to zero calls when the user has no futures @@ -73,7 +73,7 @@ export async function readAccountSnapshot( perpOrderMargin, perpFunding, futuresOrderMargin, - activeDeliveryDates, + activeExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ { @@ -103,38 +103,38 @@ export async function readAccountSnapshot( { address: config.futures.address, abi: FuturesAbi, - functionName: "getFuturesOrderMargin" as const, + functionName: "getOrderMargin" as const, args: [user] as const, }, { address: config.futures.address, abi: FuturesAbi, - functionName: "getActiveDeliveryDates" as const, + functionName: "getActiveExpirationDates" as const, args: [user] as const, }, ] as const, allowFailure: false, }); - const deliveryAts = activeDeliveryDates as readonly bigint[]; + const expirationAts = activeExpirationAts as readonly bigint[]; const futuresPositions: AccountSnapshot["futures"]["positions"] = []; - if (deliveryAts.length > 0) { + if (expirationAts.length > 0) { const positions = await chain.publicClient.multicall({ - contracts: deliveryAts.map((deliveryAt) => ({ + contracts: expirationAts.map((expirationAt) => ({ address: config.futures.address, abi: FuturesAbi, functionName: "getUserPosition" as const, - args: [user, deliveryAt] as const, + args: [user, expirationAt] as const, })), allowFailure: false, }); - for (let i = 0; i < deliveryAts.length; i++) { + for (let i = 0; i < expirationAts.length; i++) { const pos = positions[i]; - const deliveryAt = deliveryAts[i]; - if (pos === undefined || deliveryAt === undefined) continue; + const expirationAt = expirationAts[i]; + if (pos === undefined || expirationAt === undefined) continue; if (pos.netQuantity === 0n) continue; futuresPositions.push({ - deliveryAt, + expirationAt, netQuantity: pos.netQuantity, netEntryValue: pos.netEntryValue, }); diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index 731dba2..8069f82 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -205,13 +205,13 @@ export function simulateFuturesClose( ): AccountSnapshot { const closeByExpiry = new Map(); for (const c of closes) { - closeByExpiry.set(c.deliveryAt, (closeByExpiry.get(c.deliveryAt) ?? 0n) + c.closeQty); + closeByExpiry.set(c.expirationAt, (closeByExpiry.get(c.expirationAt) ?? 0n) + c.closeQty); } const remaining: AccountSnapshot["futures"]["positions"] = []; let balanceDelta = 0n; for (const pos of snap.futures.positions) { - const want = closeByExpiry.get(pos.deliveryAt) ?? 0n; + const want = closeByExpiry.get(pos.expirationAt) ?? 0n; if (want <= 0n) { remaining.push(pos); continue; @@ -231,7 +231,7 @@ export function simulateFuturesClose( if (closeAbs >= absNet) continue; const newAbs = absNet - closeAbs; remaining.push({ - deliveryAt: pos.deliveryAt, + expirationAt: pos.expirationAt, netQuantity: pos.netQuantity > 0n ? newAbs : -newAbs, netEntryValue: (pos.netEntryValue * newAbs) / absNet, }); @@ -322,7 +322,7 @@ export function solveFuturesClosesToTarget( if (!foundInBand) { // Full close every aggregate. return positions.map((p) => ({ - deliveryAt: p.deliveryAt, + expirationAt: p.expirationAt, closeQty: abs(p.netQuantity), })); } @@ -398,7 +398,7 @@ type FuturesAggregate = AccountSnapshot["futures"]["positions"][number]; /** * Expand aggregates into a unit-close sequence interleaved across expiries. - * Each unit is one whole contract at a `deliveryAt`. Groups (expiries) are + * Each unit is one whole contract at a `expirationAt`. Groups (expiries) are * ordered by total unrealized loss desc; within the sequence we round-robin * one unit from each group until books are exhausted. */ @@ -416,7 +416,7 @@ function rankUnitClosesBalancedAcrossExpirations( const na = abs(a.netQuantity) * avgEntry(a); const nb = abs(b.netQuantity) * avgEntry(b); if (na !== nb) return na < nb ? 1 : -1; - return a.deliveryAt < b.deliveryAt ? -1 : a.deliveryAt > b.deliveryAt ? 1 : 0; + return a.expirationAt < b.expirationAt ? -1 : a.expirationAt > b.expirationAt ? 1 : 0; }); const remaining = ordered.map((p) => abs(p.netQuantity)); @@ -428,7 +428,7 @@ function rankUnitClosesBalancedAcrossExpirations( const left = remaining[i] ?? 0n; if (left <= 0n) continue; remaining[i] = left - 1n; - result.push(ordered[i]!.deliveryAt); + result.push(ordered[i]!.expirationAt); progress = true; } } @@ -443,7 +443,7 @@ function coalesceUnitPrefix(unitSequence: readonly bigint[], prefixLen: number): } return [...counts.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([deliveryAt, closeQty]) => ({ deliveryAt, closeQty })); + .map(([expirationAt, closeQty]) => ({ expirationAt, closeQty })); } /** Per-aggregate unrealized loss at `P` (token decimals); 0 when in profit. */ diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts index ede6ba5..519a856 100644 --- a/keeper/src/predict/types.ts +++ b/keeper/src/predict/types.ts @@ -7,7 +7,7 @@ import type { Address } from "viem"; * * Shapes deliberately mirror the on-chain getters: * - perps: `getUserPosition` + `getOrderMargin` + `getPendingFunding` - * - futures: `getActiveDeliveryDates`/`getUserPosition` + `getFuturesOrderMargin` + * - futures: `getActiveExpirationDates`/`getUserPosition` + `getOrderMargin` * * Bigints throughout because PME math is performed in token-decimal units * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers @@ -32,19 +32,19 @@ export interface AccountSnapshot { /** * One entry per active futures expiry. Unilateral aggregate per - * `(user, deliveryAt)`: signed `netQuantity` (whole contracts) + + * `(user, expirationAt)`: signed `netQuantity` (whole contracts) + * `netEntryValue` (token decimals) so unrealized PnL is * `P * netQuantity - netEntryValue`. */ futures: { positions: Array<{ - deliveryAt: bigint; + expirationAt: bigint; /** Signed whole contracts (+long / −short). */ netQuantity: bigint; /** Token decimals; `sum(fillPrice * signedFillQty)`. */ netEntryValue: bigint; }>; - /** Constant in P: `getFuturesOrderMargin(user)`. */ + /** Constant in P: `getOrderMargin(user)`. */ orderMargin: bigint; }; } @@ -93,7 +93,7 @@ export interface AlertThresholds { /** One expiry leg of a futures close-to-IM batch. */ export interface FuturesCloseLeg { - deliveryAt: bigint; + expirationAt: bigint; /** Absolute contracts to close toward zero (≤ |netQuantity|). */ closeQty: bigint; } diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts index 3346486..8043fe2 100644 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -57,7 +57,7 @@ const RECOVERABLE_REVERTS = new Set(["OrderNotExists", "OrderNotExpired"]); interface ExpiredOrder { user: Address; orderId: Hex; - deliveryAt: bigint; + expirationAt: bigint; } export class OutdatedOrderSweeper { @@ -187,7 +187,7 @@ export class OutdatedOrderSweeper { /** * For each tracked user, read its order ids and hydrate to find - * `deliveryAt < blockTimestamp`. Per-user RPC failure is logged and + * `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. */ @@ -215,7 +215,7 @@ export class OutdatedOrderSweeper { } if (orderIds.length === 0) continue; - let orders: ReadonlyArray<{ deliveryAt: bigint }>; + let orders: ReadonlyArray<{ expirationAt: bigint }>; try { orders = (await this.chain.publicClient.multicall({ contracts: orderIds.map((id) => ({ @@ -225,7 +225,7 @@ export class OutdatedOrderSweeper { args: [id] as const, })), allowFailure: false, - })) as ReadonlyArray<{ deliveryAt: bigint }>; + })) as ReadonlyArray<{ expirationAt: bigint }>; } catch (err) { this.logger.warn( { err, user, orderCount: orderIds.length }, @@ -238,12 +238,12 @@ export class OutdatedOrderSweeper { const order = orders[i]; const orderId = orderIds[i] as Hex; if (order === undefined) continue; - // Matches the contract guard: `deliveryAt >= block.timestamp` reverts + // 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 `deliveryAt == blockTimestamp` (next + // a tx in the very-edge case `expirationAt == blockTimestamp` (next // block will satisfy it cleanly). - if (order.deliveryAt < blockTimestamp) { - expired.push({ user, orderId, deliveryAt: order.deliveryAt }); + if (order.expirationAt < blockTimestamp) { + expired.push({ user, orderId, expirationAt: order.expirationAt }); } } } diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 840522a..2bb5d0b 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -46,8 +46,8 @@ export class FuturesVenue implements Venue { } marketLabel(marketId: MarketId): string { - const deliveryAt = marketIdToDeliveryAt(marketId); - const iso = new Date(Number(deliveryAt) * 1000).toISOString().slice(0, 10); + const expirationAt = marketIdToExpirationAt(marketId); + const iso = new Date(Number(expirationAt) * 1000).toISOString().slice(0, 10); return `futures ${iso}`; } @@ -73,16 +73,16 @@ export class FuturesVenue implements Venue { return orderIds.map((id, i) => ({ id, - marketId: deliveryAtMarketId(orders[i].deliveryAt), + marketId: expirationAtMarketId(orders[i].expirationAt), })); } async readPositions(user: Address): Promise { - const [deliveryAts, marketPrice] = await Promise.all([ + const [expirationAts, marketPrice] = await Promise.all([ this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "getActiveDeliveryDates", + functionName: "getActiveExpirationDates", args: [user], }) as Promise, this.chain.publicClient.readContract({ @@ -92,21 +92,21 @@ export class FuturesVenue implements Venue { }) as Promise, ]); - if (deliveryAts.length === 0) return []; + if (expirationAts.length === 0) return []; const positions = await this.chain.publicClient.multicall({ - contracts: deliveryAts.map((deliveryAt) => ({ + contracts: expirationAts.map((expirationAt) => ({ address: this.config.futures.address, abi: FuturesAbi, functionName: "getUserPosition" as const, - args: [user, deliveryAt] as const, + args: [user, expirationAt] as const, })), allowFailure: false, }); const out: VenuePosition[] = []; - for (let i = 0; i < deliveryAts.length; i++) { - const deliveryAt = deliveryAts[i]!; + for (let i = 0; i < expirationAts.length; i++) { + const expirationAt = expirationAts[i]!; const pos = positions[i]!; if (pos.netQuantity === 0n) continue; @@ -117,8 +117,8 @@ export class FuturesVenue implements Venue { const notional = avgEntry * absQty; out.push({ - id: deliveryAtMarketId(deliveryAt), - marketId: deliveryAtMarketId(deliveryAt), + id: expirationAtMarketId(expirationAt), + marketId: expirationAtMarketId(expirationAt), unrealizedLoss, notional, }); @@ -167,7 +167,7 @@ export class FuturesVenue implements Venue { // Gas-bounded chunking: send at most `maxLotsPerLiquidationTx` expiry legs. const cap = this.config.futures.maxLotsPerLiquidationTx; const chunk = cap > 0 && closes.length > cap ? closes.slice(0, cap) : closes; - const deliveryAts = chunk.map((c) => c.deliveryAt); + const expirationAts = chunk.map((c) => c.expirationAt); const closeQtys = chunk.map((c) => c.closeQty); const contractsClosed = closeQtys.reduce((s, q) => s + q, 0n); @@ -190,7 +190,7 @@ export class FuturesVenue implements Venue { address: this.config.futures.address, abi: FuturesAbi, functionName: "liquidatePositions", - args: [user, deliveryAts, closeQtys], + args: [user, expirationAts, closeQtys], feeEventName: "PositionLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; @@ -215,12 +215,12 @@ function abs(x: bigint): bigint { return x < 0n ? -x : x; } -/** `bytes32(uint256(deliveryAt))` — same encoding the indexer uses. */ -export function deliveryAtMarketId(deliveryAt: bigint): MarketId { - return pad(toHex(deliveryAt), { size: 32 }); +/** `bytes32(uint256(expirationAt))` — same encoding the indexer uses. */ +export function expirationAtMarketId(expirationAt: bigint): MarketId { + return pad(toHex(expirationAt), { size: 32 }); } -/** Inverse of `deliveryAtMarketId` — used by the planner / labels. */ -export function marketIdToDeliveryAt(marketId: MarketId): bigint { +/** Inverse of `expirationAtMarketId` — used by the planner / labels. */ +export function marketIdToExpirationAt(marketId: MarketId): bigint { return BigInt(marketId); } diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index 97d7832..b21252d 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -6,7 +6,7 @@ import type { Address, Hex } from "viem"; * Encoded forms (callers MUST treat this as opaque — only the venue itself * decodes it): * - perps: sentinel `keccak256("perps")` (single market) - * - futures: bytes32(uint256(deliveryAt)) + * - futures: bytes32(uint256(expirationAt)) * - options: keccak256(abi.encode(strike, expiry)) * * Kept opaque so the coordinator can rank cross-market positions without @@ -96,7 +96,7 @@ export interface Venue { * `[MM, IM]` band (futures: per-expiry `closeQty` legs; perps: a partial * `closeQty`). Deep-underwater accounts with no in-band partial size to * a full close. - * 3. Submit ONE tx — futures `liquidatePositions(user, deliveryAts[], + * 3. Submit ONE tx — futures `liquidatePositions(user, expirationAts[], * closeQtys[])`, perps `liquidatePosition(user, closeQty)`. * * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first) or diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index b177d9e..97f5890 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -46,20 +46,20 @@ interface MatchedLog { args: { maker: Address; taker: Address; - deliveryAt: bigint; + expirationAt: bigint; makerNetQtyAfter: bigint; takerNetQtyAfter: bigint; }; } interface SettledLog { - args: { user: Address; deliveryAt: bigint }; + args: { user: Address; expirationAt: bigint }; } function matchedLog( maker: Address, taker: Address, - deliveryAt: bigint, + expirationAt: bigint, makerQty: bigint, takerQty: bigint, ): MatchedLog { @@ -67,15 +67,15 @@ function matchedLog( args: { maker, taker, - deliveryAt, + expirationAt, makerNetQtyAfter: makerQty, takerNetQtyAfter: takerQty, }, }; } -function settledLog(user: Address, deliveryAt: bigint): SettledLog { - return { args: { user, deliveryAt } }; +function settledLog(user: Address, expirationAt: bigint): SettledLog { + return { args: { user, expirationAt } }; } interface ChainStubOptions { @@ -97,8 +97,8 @@ interface ChainStubOptions { readContractError?: (functionName: string) => Error | undefined; } -function posKey(user: Address, deliveryAt: bigint): string { - return `${user.toLowerCase()}:${deliveryAt}`; +function posKey(user: Address, expirationAt: bigint): string { + return `${user.toLowerCase()}:${expirationAt}`; } function makeChain(opts: ChainStubOptions = {}): Chain { @@ -120,7 +120,7 @@ function makeChain(opts: ChainStubOptions = {}): Chain { readContract: async ({ functionName, args }: { functionName: string; args?: readonly unknown[] }) => { const err = opts.readContractError?.(functionName); if (err) throw err; - if (functionName === "getActiveDeliveryDates") { + if (functionName === "getActiveExpirationDates") { const user = (args?.[0] as Address).toLowerCase(); return opts.activeDatesByUser?.[user] ?? []; } @@ -134,13 +134,13 @@ function makeChain(opts: ChainStubOptions = {}): Chain { return contracts.map((c) => { if (c.functionName === "getUserPosition") { const user = (c.args?.[0] as Address).toLowerCase(); - const deliveryAt = c.args?.[1] as bigint; - const key = `${user}:${deliveryAt}`; + const expirationAt = c.args?.[1] as bigint; + const key = `${user}:${expirationAt}`; return ( opts.positionsByUserDate?.[key] ?? { netQuantity: 0n, netEntryValue: 0n } ); } - if (c.functionName === "getActiveDeliveryDates") { + if (c.functionName === "getActiveExpirationDates") { const user = (c.args?.[0] as Address).toLowerCase(); return opts.activeDatesByUser?.[user] ?? []; } @@ -273,17 +273,17 @@ describe("delivery/coordinator: bootstrap + settle", () => { assert.equal(coord.has(USER_A, DELIVERY_B), true); }); - it("indexUserPositions swallows getActiveDeliveryDates RPC errors", async () => { + it("indexUserPositions swallows getActiveExpirationDates RPC errors", async () => { const chain = makeChain({ readContractError: (fn) => - fn === "getActiveDeliveryDates" ? new Error("rpc down") : undefined, + fn === "getActiveExpirationDates" ? new Error("rpc down") : undefined, }); const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coord.indexUserPositions(USER_A); // must not throw assert.equal(coord.size(), 0); }); - it("settleBatch simulates settlePosition(user, deliveryAt) and drops on success", async () => { + it("settleBatch simulates settlePosition(user, expirationAt) and drops on success", async () => { const simulated: unknown[][] = []; const chain = makeChain({ // Far-future timestamp so bootstrap's trailing sweep is a no-op. diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index cb57f01..8caf98c 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -114,7 +114,7 @@ export interface DeployedStack { perpsMakerFeeBps: bigint; futuresTakerFee: bigint; futuresLiquidationFee: bigint; - futuresFirstDeliveryDate: bigint; + futuresFirstExpirationAt: bigint; insuranceFund: bigint; initialUserBalance: bigint; }; @@ -253,10 +253,10 @@ export async function deployStack(rpcUrl: string): Promise { const latestBlock = await publicClient.getBlock(); // First expiry sits one interval out from now (the duration constant is gone — // hashpower settles per-day, so only the expiry spacing schedules the book). - const firstDeliveryDate = + const firstExpirationAt = latestBlock.timestamp + BigInt(FUTURES_EXPIRATION_INTERVAL_DAYS * 24 * 3600); // initialize(hashrateOracle, liquidationMarginPercent, minimumPriceIncrement, - // expirationIntervalDays, futureDeliveryDatesCount, firstFutureDeliveryDate) + // expirationIntervalDays, futureExpirationDatesCount, firstFutureExpirationDate) const futures = await deployProxy( publicClient, owner.client, @@ -269,7 +269,7 @@ export async function deployStack(rpcUrl: string): Promise { MIN_PRICE_INCREMENT, FUTURES_EXPIRATION_INTERVAL_DAYS, FUTURES_FUTURE_DELIVERY_DATES_COUNT, - firstDeliveryDate, + firstExpirationAt, ], ); @@ -428,7 +428,7 @@ export async function deployStack(rpcUrl: string): Promise { perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, futuresTakerFee: FUTURES_TAKER_FEE, futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, - futuresFirstDeliveryDate: firstDeliveryDate, + futuresFirstExpirationAt: firstExpirationAt, insuranceFund: INSURANCE_FUND, initialUserBalance: INITIAL_USER_BALANCE, }, diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 2e7e01d..0ff4d7b 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -108,7 +108,7 @@ export async function readFuturesActiveDates( return (await stack.publicClient.readContract({ address: stack.addresses.futures, abi: stack.abis.futures, - functionName: "getActiveDeliveryDates", + functionName: "getActiveExpirationDates", args: [user], })) as readonly bigint[]; } @@ -119,12 +119,12 @@ export async function readFuturesPositionIds( user: Address, ): Promise { const dates = await readFuturesActiveDates(stack, user); - // Encode deliveryAt as bytes32 for callers that still treat them as Hex ids. + // Encode expirationAt as bytes32 for callers that still treat them as Hex ids. return dates.map((d) => `0x${d.toString(16).padStart(64, "0")}` as Hex); } /** - * Decodes Hex-encoded deliveryAt values (from `readFuturesPositionIds`) back + * Decodes Hex-encoded expirationAt values (from `readFuturesPositionIds`) back * to bigint expiries. Kept for multi-expiry balancing tests. */ export async function readFuturesLotExpiries( @@ -291,13 +291,13 @@ export async function readFuturesClosedQuantity( export async function readFuturesNetQuantity( stack: DeployedStack, user: Address, - deliveryAt: bigint, + expirationAt: bigint, ): Promise { const pos = (await stack.publicClient.readContract({ address: stack.addresses.futures, abi: stack.abis.futures, functionName: "getUserPosition", - args: [user, deliveryAt], + args: [user, expirationAt], })) as { netQuantity: bigint }; return pos.netQuantity; } @@ -356,20 +356,20 @@ export const readFuturesOrderLiquidationBlock = (s: DeployedStack, u: Address) = earliestEventBlock(s, "futures", "OrderLiquidated", { user: u }); /** - * Earliest block at which `Futures.PositionSettled(user, deliveryAt)` was - * emitted. `deliveryAtHex` is the bytes32 encoding from `readFuturesPositionIds`. + * Earliest block at which `Futures.PositionSettled(user, expirationAt)` was + * emitted. `expirationAtHex` is the bytes32 encoding from `readFuturesPositionIds`. */ export async function readLotClosedBlock( stack: DeployedStack, user: Address, - deliveryAtHex: Hex, + expirationAtHex: Hex, ): Promise { - const deliveryAt = BigInt(deliveryAtHex); + const expirationAt = BigInt(expirationAtHex); const logs = await stack.publicClient.getContractEvents({ address: stack.addresses.futures, abi: stack.abis.futures, eventName: "PositionSettled", - args: { user, deliveryAt }, + args: { user, expirationAt }, fromBlock: 0n, }); let earliest: bigint | null = null; diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index e645b10..d42b98f 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -385,18 +385,18 @@ describe("Liquidate down to the IM buffer", () => { async () => { // Precondition: alice holds one aggregate long of 12 contracts; a moderate // crash ($40 → $30 mark) breaks MM but a partial closeQty restores the IM - // buffer. A single `liquidatePositions(user, deliveryAts[], closeQtys[])` + // buffer. A single `liquidatePositions(user, expirationAts[], closeQtys[])` // must land `MM <= balance <= IM` without full-closing the aggregate. const ctx = await loadFixture(futuresPartialCrashFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); const alice = ctx.accounts.alice.account.address; - const deliveryAt = ctx.config.futuresFirstDeliveryDate; + const expirationAt = ctx.config.futuresFirstExpirationAt; const datesBefore = await readFuturesPositionIds(ctx, alice); assert.equal(datesBefore.length, 1, "precondition: one active expiry"); assert.equal( - await readFuturesNetQuantity(ctx, alice, deliveryAt), + await readFuturesNetQuantity(ctx, alice, expirationAt), BigInt(ctx.aliceFuturesQty), "precondition: aggregate net qty equals matched contracts", ); @@ -406,7 +406,7 @@ describe("Liquidate down to the IM buffer", () => { await expectReducedToImBuffer(ctx, alice); - const netAfter = await readFuturesNetQuantity(ctx, alice, deliveryAt); + const netAfter = await readFuturesNetQuantity(ctx, alice, expirationAt); assert.ok(netAfter > 0n, `expected partial close (qty remaining), got ${netAfter}`); assert.ok( netAfter < BigInt(ctx.aliceFuturesQty), @@ -437,10 +437,10 @@ describe("Liquidate down to the IM buffer", () => { const datesBefore = await readFuturesPositionIds(ctx, alice); assert.equal( datesBefore.length, - ctx.deliveryDates.length, + ctx.expirationAts.length, "precondition: one aggregate per delivery date", ); - const [firstDelivery, secondDelivery] = ctx.deliveryDates; + const [firstDelivery, secondDelivery] = ctx.expirationAts; assert.ok(firstDelivery !== undefined && secondDelivery !== undefined); assert.equal( await readFuturesNetQuantity(ctx, alice, firstDelivery), @@ -536,7 +536,7 @@ describe("Liquidate down to the IM buffer", () => { const futuresBefore = await readFuturesPositionIds(ctx, alice); assert.equal(futuresBefore.length, 1, "precondition: one futures aggregate"); assert.equal( - await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), BigInt(ctx.aliceFuturesQty), ); @@ -562,7 +562,7 @@ describe("Liquidate down to the IM buffer", () => { `expected the futures leg untouched, before=${futuresBefore.length} after=${futuresAfter.length}`, ); assert.equal( - await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), BigInt(ctx.aliceFuturesQty), "futures net qty unchanged", ); @@ -596,7 +596,7 @@ describe("Liquidate down to the IM buffer", () => { const futuresBefore = await readFuturesPositionIds(ctx, alice); assert.equal(futuresBefore.length, 1, "precondition: one futures aggregate"); assert.equal( - await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), BigInt(ctx.aliceFuturesQty), "precondition: alice holds 12 futures contracts", ); @@ -935,7 +935,7 @@ describe("DeliveryCoordinator (live RPC)", () => { // validator key is used here for historical parity, but `settlePosition` // is permissionless — see the dedicated non-validator test below.) // - // We then fast-forward the chain past `deliveryAt` and trigger one + // We then fast-forward the chain past `expirationAt` and trigger one // sweep. `settlePosition` cash-settles the full position notional at the // current market price and emits `PositionSettled`. const ctx = await loadFixture(futuresLongCrashFixture, testClient); @@ -951,7 +951,7 @@ describe("DeliveryCoordinator (live RPC)", () => { // 3.0: one unilateral aggregate per expiry (12 contracts → 1 active date). assert.equal(positionsBefore.length, 1); assert.equal( - await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstDeliveryDate), + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), BigInt(ctx.aliceFuturesQty), ); @@ -962,11 +962,11 @@ describe("DeliveryCoordinator (live RPC)", () => { assert.ok(keeper.delivery.has(alice, BigInt(id)), `backfill should index position ${id}`); } - // Fast-forward past `deliveryAt`. `settlePosition` requires - // `block.timestamp >= position.deliveryAt`, and `block.timestamp` is + // Fast-forward past `expirationAt`. `settlePosition` requires + // `block.timestamp >= position.expirationAt`, and `block.timestamp` is // only advanced once a block is mined at the new clock. - const deliveryAt = ctx.config.futuresFirstDeliveryDate; - await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 60n }); await testClient.mine({ blocks: 1 }); // The hashprice oracle has been silent for 7 days — refresh it so @@ -1019,7 +1019,7 @@ describe("DeliveryCoordinator (live RPC)", () => { { timeout: 60_000 }, async () => { // Precondition: alice's position was created at fixture time and - // its `deliveryAt` is *already in the past* by the time the keeper + // its `expirationAt` is *already in the past* by the time the keeper // boots. The contract is the spec for "missing delivery": until // someone calls `settlePosition` the position lingers, and (unlike the // old closeDelivery window) it stays settleable indefinitely. @@ -1029,10 +1029,10 @@ describe("DeliveryCoordinator (live RPC)", () => { // no live event, no scheduler tick required. const ctx = await loadFixture(futuresLongCrashFixture, testClient); - // Move time past deliveryAt *before* the keeper boots, so the live + // Move time past expirationAt *before* the keeper boots, so the live // subscription would miss the (long-past) OrderMatched event. - const deliveryAt = ctx.config.futuresFirstDeliveryDate; - await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 120n }); await testClient.mine({ blocks: 1 }); // Refresh the oracle so `_getHashpriceUsd` doesn't revert `OracleStale` // when settlement reads the mark. @@ -1070,7 +1070,7 @@ describe("DeliveryCoordinator (live RPC)", () => { // Production reality: on Alchemy free tier `eth_getLogs` is capped // at 10 blocks, so log-based backfill is unusable for any non-trivial // window. The view-based discovery path (`bootstrapFromUsers`) reads - // ``getActiveDeliveryDates` + `getUserPosition` directly from contract storage, + // ``getActiveExpirationDates` + `getUserPosition` directly from contract storage, // sidestepping the log limit entirely. This test exercises that exact // recovery shape: we never call `backfill()` — only `bootstrapFromUsers` // — and verify every still-alive position is found and settled. @@ -1091,8 +1091,8 @@ describe("DeliveryCoordinator (live RPC)", () => { assert.ok(keeper.delivery.has(alice, BigInt(id)), `bootstrap should index position ${id}`); } - const deliveryAt = ctx.config.futuresFirstDeliveryDate; - await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 60n }); await testClient.mine({ blocks: 1 }); await ctx.bumpHashprice(ctx.config.initialHashprice); @@ -1121,11 +1121,11 @@ describe("DeliveryCoordinator (live RPC)", () => { // the view path and settles them on the first sweep. const ctx = await loadFixture(futuresLongCrashFixture, testClient); - // Move past deliveryAt before boot — same shape as the production + // Move past expirationAt before boot — same shape as the production // outage where the keeper has been down/blind during the delivery // window. - const deliveryAt = ctx.config.futuresFirstDeliveryDate; - await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 120n }); + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 120n }); await testClient.mine({ blocks: 1 }); await ctx.bumpHashprice(ctx.config.initialHashprice); @@ -1183,8 +1183,8 @@ describe("DeliveryCoordinator (live RPC)", () => { await keeper.delivery.backfill(0n, 10_000n); - const deliveryAt = ctx.config.futuresFirstDeliveryDate; - await testClient.setNextBlockTimestamp({ timestamp: deliveryAt + 60n }); + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 60n }); await testClient.mine({ blocks: 1 }); await ctx.bumpHashprice(ctx.config.initialHashprice); diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 80815c5..418ddab 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -113,7 +113,7 @@ export interface FuturesLongFixture extends BaseFixture { /** Alice holds futures longs across multiple delivery dates. */ export interface MultiFuturesFixture extends BaseFixture { aliceDeposit: bigint; - deliveryDates: readonly bigint[]; + expirationAts: readonly bigint[]; makeLiquidatable(): Promise; } @@ -155,7 +155,7 @@ export interface PerpsPartialCrashFixture extends BaseFixture { export interface MultiExpiryFuturesPartialCrashFixture extends BaseFixture { aliceDeposit: bigint; /** The two delivery dates Alice holds lots on. */ - deliveryDates: readonly [bigint, bigint]; + expirationAts: readonly [bigint, bigint]; /** Lots per delivery date (equal split). */ perExpiryQty: number; makeLiquidatable(): Promise; @@ -423,7 +423,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -461,7 +461,7 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -472,7 +472,7 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { base, base.accounts.alice, restingPrice, - base.config.futuresFirstDeliveryDate, + base.config.futuresFirstExpirationAt, 1, ); @@ -497,19 +497,19 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { const base = await baseFixture(rpcUrl); const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); - const firstDeliveryAt = base.config.futuresFirstDeliveryDate; + const firstExpirationAt = base.config.futuresFirstExpirationAt; // Must match on-chain Futures.EXPIRATION_INTERVAL_DAYS (= 30). - const secondDeliveryAt = firstDeliveryAt + BigInt(30 * 24 * 3600); + const secondExpirationAt = firstExpirationAt + BigInt(30 * 24 * 3600); await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); - for (const deliveryAt of [firstDeliveryAt, secondDeliveryAt]) { + for (const expirationAt of [firstExpirationAt, secondExpirationAt]) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt, + expirationAt, quantity: 6, }); } @@ -517,7 +517,7 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { return { ...base, aliceDeposit, - deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, + expirationAts: [firstExpirationAt, secondExpirationAt] as const, makeLiquidatable: () => base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; @@ -571,7 +571,7 @@ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: entryMark, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -607,8 +607,8 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { const aliceDeposit = parseUnits("136", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const perExpiryQty = 6; - const firstDeliveryAt = base.config.futuresFirstDeliveryDate; - const secondDeliveryAt = firstDeliveryAt + BigInt(30 * 24 * 3600); // Futures.EXPIRATION_INTERVAL_DAYS + const firstExpirationAt = base.config.futuresFirstExpirationAt; + const secondExpirationAt = firstExpirationAt + BigInt(30 * 24 * 3600); // Futures.EXPIRATION_INTERVAL_DAYS // Zero the taker fee (see `futuresPartialCrashFixtureBuilder`) so the 12-lot // entry IM ($48) fits the $136 deposit; the liquidation fee still applies. @@ -617,12 +617,12 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); - for (const deliveryAt of [firstDeliveryAt, secondDeliveryAt]) { + for (const expirationAt of [firstExpirationAt, secondExpirationAt]) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, price: entryMark, - deliveryAt, + expirationAt, quantity: perExpiryQty, }); } @@ -630,7 +630,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { return { ...base, aliceDeposit, - deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, + expirationAts: [firstExpirationAt, secondExpirationAt] as const, perExpiryQty, makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; @@ -728,7 +728,7 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -808,7 +808,7 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: entryMark, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -856,7 +856,7 @@ function crossVenueFixtureBody( buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -928,7 +928,7 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { buyer: base.accounts.alice, seller: base.accounts.bob, price: base.config.initialMarketPrice, - deliveryAt: base.config.futuresFirstDeliveryDate, + expirationAt: base.config.futuresFirstExpirationAt, quantity: aliceFuturesQty, }); @@ -945,7 +945,7 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { base, base.accounts.alice, parseUnits("2.00", base.config.oracleDecimals), - base.config.futuresFirstDeliveryDate, + base.config.futuresFirstExpirationAt, 1, ); @@ -1008,15 +1008,15 @@ interface FuturesTrade { buyer: Wallet; seller: Wallet; price: bigint; - deliveryAt: bigint; + expirationAt: bigint; /** Whole contracts (signed at placement: +buy / −sell). */ quantity: number; } /** Same shape as `matchPerpsTrade`, but for the Futures venue. */ async function matchFuturesTrade(base: BaseFixture, t: FuturesTrade): Promise { - await placeFuturesOrder(base, t.seller, t.price, t.deliveryAt, -t.quantity); - await placeFuturesOrder(base, t.buyer, t.price, t.deliveryAt, t.quantity); + await placeFuturesOrder(base, t.seller, t.price, t.expirationAt, -t.quantity); + await placeFuturesOrder(base, t.buyer, t.price, t.expirationAt, t.quantity); } async function placePerpsOrder( @@ -1040,15 +1040,15 @@ async function placeFuturesOrder( base: BaseFixture, wallet: Wallet, price: bigint, - deliveryAt: bigint, + expirationAt: bigint, qty: number, ): Promise { - // Futures 3.0: createOrder(price, deliveryAt, signedQuantity) — whole contracts. + // Futures 3.0: createOrder(price, expirationAt, signedQuantity) — whole contracts. const hash = await wallet.client.writeContract({ address: base.addresses.futures, abi: base.abis.futures, functionName: "createOrder", - args: [price, deliveryAt, BigInt(qty)], + args: [price, expirationAt, BigInt(qty)], chain: hardhat, account: wallet.account, }); diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 0276a6a..3f92ca4 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -125,9 +125,9 @@ function buildHarness({ return 0n; case "getPendingFunding": return 0n; - case "getFuturesOrderMargin": + case "getOrderMargin": return 0n; - case "getActiveDeliveryDates": + case "getActiveExpirationDates": return []; case "computePortfolioIM": return balance / 2n; diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 9d75070..8f282e1 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -100,9 +100,9 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return 0n; case "getPendingFunding": return 0n; - case "getFuturesOrderMargin": + case "getOrderMargin": return 0n; - case "getActiveDeliveryDates": + case "getActiveExpirationDates": return []; case "computePortfolioIM": return imAtPriceTokens(currentPrice); diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts index 1b9183d..c082f01 100644 --- a/keeper/tests/predict/mm.test.ts +++ b/keeper/tests/predict/mm.test.ts @@ -62,7 +62,7 @@ describe("predict/mm: netDeltaWad", () => { // Buyer of 1 contract → +1 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { - positions: [{ deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], + positions: [{ expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], orderMargin: 0n, }, }); @@ -72,7 +72,7 @@ describe("predict/mm: netDeltaWad", () => { it("subtracts futures seller delta", () => { const snap = emptySnapshot({ futures: { - positions: [{ deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], + positions: [{ expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], orderMargin: 0n, }, }); @@ -84,8 +84,8 @@ describe("predict/mm: netDeltaWad", () => { perp: { netQty: 1_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, // +1e18 futures: { positions: [ - { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, - { deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -60n }, + { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, + { expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -60n }, ], orderMargin: 0n, }, @@ -168,7 +168,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("buyer loses when P drops below entry (no duration factor)", () => { const snap = emptySnapshot({ futures: { - positions: [{ deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], + positions: [{ expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], orderMargin: 0n, }, }); @@ -179,7 +179,7 @@ describe("predict/mm: futuresUnrealizedLoss", () => { it("seller loses when P rises above entry", () => { const snap = emptySnapshot({ futures: { - positions: [{ deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], + positions: [{ expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], orderMargin: 0n, }, }); @@ -190,14 +190,14 @@ describe("predict/mm: futuresUnrealizedLoss", () => { const snap = emptySnapshot({ futures: { positions: [ - { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, // P=40 → loses 10 - { deliveryAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -30n }, // P=40 → loses 10 + { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, // P=40 → loses 10 + { expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -30n }, // P=40 → loses 10 ], orderMargin: 0n, }, }); // Loss is sum of *losing* legs only (consistent with `max(0, -pnl)` per leg - // mirroring the on-chain `getFuturesUnrealizedPnl` aggregation, which + // mirroring the on-chain `getUnrealizedPnl` aggregation, which // would be 0 net but PME treats them piecewise via stress + per-leg PnL). // Here both happen to be losing — buyer down, seller up. assert.equal(futuresUnrealizedLoss(snap, 40n), 20n); diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index f1c0492..cd72ebd 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -24,7 +24,7 @@ function makeConfig(): Config { } function makeChain(scripted: { - activeDeliveryDates?: readonly bigint[]; + activeExpirationAts?: readonly bigint[]; futuresPositions?: Record; perpNetQty?: bigint; perpEntry?: bigint; @@ -49,11 +49,11 @@ function makeChain(scripted: { case "balanceOf": return scripted.balance ?? 0n; case "getUserPosition": { - // Perps: getUserPosition(user). Futures: getUserPosition(user, deliveryAt). + // Perps: getUserPosition(user). Futures: getUserPosition(user, expirationAt). if ((c.args?.length ?? 0) >= 2) { - const deliveryAt = c.args?.[1] as bigint; - const pos = scripted.futuresPositions?.[deliveryAt.toString()]; - if (pos === undefined) throw new Error(`unscripted futures position ${deliveryAt}`); + const expirationAt = c.args?.[1] as bigint; + const pos = scripted.futuresPositions?.[expirationAt.toString()]; + if (pos === undefined) throw new Error(`unscripted futures position ${expirationAt}`); return pos; } return { @@ -65,10 +65,10 @@ function makeChain(scripted: { return scripted.perpOrderMargin ?? 0n; case "getPendingFunding": return scripted.perpFunding ?? 0n; - case "getFuturesOrderMargin": + case "getOrderMargin": return scripted.futuresOrderMargin ?? 0n; - case "getActiveDeliveryDates": - return scripted.activeDeliveryDates ?? []; + case "getActiveExpirationDates": + return scripted.activeExpirationAts ?? []; case "imSpotShock": return scripted.imShock ?? 10n ** 17n; case "mmSpotShock": @@ -121,7 +121,7 @@ describe("predict/snapshot: readAccountSnapshot", () => { it("hydrates futures aggregates from active delivery dates", async () => { const chain = makeChain({ - activeDeliveryDates: [EXPIRY_A, EXPIRY_B], + activeExpirationAts: [EXPIRY_A, EXPIRY_B], futuresPositions: { [EXPIRY_A.toString()]: { netQuantity: 1n, netEntryValue: 50n }, [EXPIRY_B.toString()]: { netQuantity: -2n, netEntryValue: -118n }, @@ -129,8 +129,8 @@ describe("predict/snapshot: readAccountSnapshot", () => { }); const snap = await readAccountSnapshot(chain, makeConfig(), USER); assert.equal(snap.futures.positions.length, 2); - const long = snap.futures.positions.find((p) => p.deliveryAt === EXPIRY_A); - const short = snap.futures.positions.find((p) => p.deliveryAt === EXPIRY_B); + const long = snap.futures.positions.find((p) => p.expirationAt === EXPIRY_A); + const short = snap.futures.positions.find((p) => p.expirationAt === EXPIRY_B); assert.equal(long?.netQuantity, 1n); assert.equal(long?.netEntryValue, 50n); assert.equal(short?.netQuantity, -2n); diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts index 53ff4ae..47e8902 100644 --- a/keeper/tests/predict/solve.test.ts +++ b/keeper/tests/predict/solve.test.ts @@ -146,7 +146,7 @@ describe("predict/solve: solveLiquidationThresholds", () => { balance: 30_000_000n, futures: { positions: [ - { deliveryAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50_000_000n }, + { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50_000_000n }, ], orderMargin: 0n, }, diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts index 455eda1..efcbbf3 100644 --- a/keeper/tests/predict/solveTarget.test.ts +++ b/keeper/tests/predict/solveTarget.test.ts @@ -28,9 +28,9 @@ const ENTRY = 40_000_000n; // $40/contract entry const P_MODERATE = 30_000_000n; // $30: underwater but recoverable const BALANCE = 136_000_000n; -function futuresAgg(netQuantity: bigint, entry: bigint, deliveryAt = EXPIRY_A) { +function futuresAgg(netQuantity: bigint, entry: bigint, expirationAt = EXPIRY_A) { return { - deliveryAt, + expirationAt, netQuantity, netEntryValue: entry * netQuantity, }; @@ -90,7 +90,7 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { const qty = totalCloseQty(closes); if (qty < 11n) { const oneMore: FuturesCloseLeg[] = [ - { deliveryAt: EXPIRY_A, closeQty: qty + 1n }, + { expirationAt: EXPIRY_A, closeQty: qty + 1n }, ]; const after = simulateFuturesClose(snap, oneMore, P, FEE); assert.ok( @@ -135,8 +135,8 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); assert.ok(totalCloseQty(closes) > 1n); - const countA = closes.find((c) => c.deliveryAt === EXPIRY_A)?.closeQty ?? 0n; - const countB = closes.find((c) => c.deliveryAt === EXPIRY_B)?.closeQty ?? 0n; + const countA = closes.find((c) => c.expirationAt === EXPIRY_A)?.closeQty ?? 0n; + const countB = closes.find((c) => c.expirationAt === EXPIRY_B)?.closeQty ?? 0n; assert.ok(countA >= 1n && countB >= 1n, `both expirations must be reduced (A=${countA}, B=${countB})`); assert.ok( countA - countB <= 1n && countB - countA <= 1n, @@ -161,8 +161,8 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { }); const P = P_MODERATE; const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); - const countA = closes.find((c) => c.deliveryAt === EXPIRY_A)?.closeQty ?? 0n; - const countB = closes.find((c) => c.deliveryAt === EXPIRY_B)?.closeQty ?? 0n; + const countA = closes.find((c) => c.expirationAt === EXPIRY_A)?.closeQty ?? 0n; + const countB = closes.find((c) => c.expirationAt === EXPIRY_B)?.closeQty ?? 0n; // A is twice B → roughly 2:1 close ratio when both are touched. if (countA > 0n && countB > 0n) { assert.ok(countA >= countB, `A=${countA} should close at least as many as B=${countB}`); diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index 2f3c568..902a543 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -53,7 +53,7 @@ function makeRecordingLogger(): { logger: pino.Logger; calls: LogCall[] } { interface FakeOrder { participant: Address; - deliveryAt: bigint; + expirationAt: bigint; } interface FakeChainOpts { @@ -111,14 +111,14 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { throw new Error(`order not found in fake state: ${id}`); } // Return shape matches the on-chain Order struct; sweeper only - // reads `deliveryAt` but include the other fields so tests + // reads `expirationAt` but include the other fields so tests // stay close to the real ABI. return { isBuy: true, participant: order.participant, destURL: "", pricePerDay: 0n, - deliveryAt: order.deliveryAt, + expirationAt: order.expirationAt, createdAt: 0n, }; }); @@ -244,14 +244,14 @@ describe("OutdatedOrderSweeper", () => { assert.equal(recorded.multicallReadCalls, 0); }); - it("ignores orders whose deliveryAt is still in the future", async () => { + 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, deliveryAt: 5_000n }], // future + [orderId, { participant: USER_A, expirationAt: 5_000n }], // future ]), }); const sweeper = new OutdatedOrderSweeper( @@ -275,9 +275,9 @@ describe("OutdatedOrderSweeper", () => { blockTimestamp: 10_000n, orderIdsByUser: new Map([[USER_A, [id1, id2, id3]]]), orders: new Map([ - [id1, { participant: USER_A, deliveryAt: 5_000n }], // expired - [id2, { participant: USER_A, deliveryAt: 9_999n }], // expired - [id3, { participant: USER_A, deliveryAt: 20_000n }], // future + [id1, { participant: USER_A, expirationAt: 5_000n }], // expired + [id2, { participant: USER_A, expirationAt: 9_999n }], // expired + [id3, { participant: USER_A, expirationAt: 20_000n }], // future ]), }); @@ -320,8 +320,8 @@ describe("OutdatedOrderSweeper", () => { [USER_B, [idB]], ]), orders: new Map([ - [idA, { participant: USER_A, deliveryAt: 5_000n }], - [idB, { participant: USER_B, deliveryAt: 5_000n }], + [idA, { participant: USER_A, expirationAt: 5_000n }], + [idB, { participant: USER_B, expirationAt: 5_000n }], ]), }); const sweeper = new OutdatedOrderSweeper( @@ -347,7 +347,7 @@ describe("OutdatedOrderSweeper", () => { 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, deliveryAt: 1n }); + orders.set(id, { participant: USER_A, expirationAt: 1n }); } const { chain, recorded } = makeChain({ blockTimestamp: 1_000n, @@ -373,7 +373,7 @@ describe("OutdatedOrderSweeper", () => { 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 - // deliveryAt bumped. The sweeper must skip them silently and still + // 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; @@ -383,9 +383,9 @@ describe("OutdatedOrderSweeper", () => { blockTimestamp: 10_000n, orderIdsByUser: new Map([[USER_A, [id1, id2, id3]]]), orders: new Map([ - [id1, { participant: USER_A, deliveryAt: 5_000n }], - [id2, { participant: USER_A, deliveryAt: 6_000n }], - [id3, { participant: USER_A, deliveryAt: 7_000n }], + [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"; @@ -418,7 +418,7 @@ describe("OutdatedOrderSweeper", () => { const { chain, recorded } = makeChain({ blockTimestamp: 10_000n, orderIdsByUser: new Map([[USER_A, [id1]]]), - orders: new Map([[id1, { participant: USER_A, deliveryAt: 1n }]]), + orders: new Map([[id1, { participant: USER_A, expirationAt: 1n }]]), }); const config = makeConfig(); (config as { keeper: { dryRun: boolean } }).keeper.dryRun = true; @@ -445,7 +445,7 @@ describe("OutdatedOrderSweeper", () => { const idB = ("0x" + "bb".repeat(32)) as Hex; const orderIdsByUser = new Map([[USER_B, [idB]]]); const orders = new Map([ - [idB, { participant: USER_B, deliveryAt: 1n }], + [idB, { participant: USER_B, expirationAt: 1n }], ]); const blockTimestamp = 10_000n; @@ -478,7 +478,7 @@ describe("OutdatedOrderSweeper", () => { participant: order.participant, destURL: "", pricePerDay: 0n, - deliveryAt: order.deliveryAt, + expirationAt: order.expirationAt, createdAt: 0n, }; }); diff --git a/keeper/tests/venues/futures-marketid.test.ts b/keeper/tests/venues/futures-marketid.test.ts index 4e27a69..8c54d89 100644 --- a/keeper/tests/venues/futures-marketid.test.ts +++ b/keeper/tests/venues/futures-marketid.test.ts @@ -1,18 +1,18 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { deliveryAtMarketId, marketIdToDeliveryAt } from "../../src/venues/futures.ts"; +import { expirationAtMarketId, marketIdToExpirationAt } from "../../src/venues/futures.ts"; describe("futures venue marketId helpers", () => { it("encodes a delivery date as bytes32 and round-trips", () => { - const deliveryAt = 1_756_416_000n; // 2025-08-29T00:00:00Z - const id = deliveryAtMarketId(deliveryAt); + const expirationAt = 1_756_416_000n; // 2025-08-29T00:00:00Z + const id = expirationAtMarketId(expirationAt); assert.equal(id.length, 66, "bytes32 hex string is 0x + 64 chars"); - assert.equal(marketIdToDeliveryAt(id), deliveryAt); + assert.equal(marketIdToExpirationAt(id), expirationAt); }); it("encodes 0 as the zero bytes32", () => { assert.equal( - deliveryAtMarketId(0n), + expirationAtMarketId(0n), "0x0000000000000000000000000000000000000000000000000000000000000000", ); }); diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts index a76b492..f2e86c7 100644 --- a/keeper/tests/venues/futures.test.ts +++ b/keeper/tests/venues/futures.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { Address, Hex } from "viem"; -import { FuturesVenue, deliveryAtMarketId } from "../../src/venues/futures.ts"; +import { FuturesVenue, expirationAtMarketId } from "../../src/venues/futures.ts"; import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; @@ -50,7 +50,7 @@ const DELIVERY_AT = 1_756_416_000n; function makeReadHandler(marketPrice: bigint, listResult: readonly unknown[]) { return (call: ReadCall): unknown => { if (call.functionName === "getMarketPrice") return marketPrice; - if (call.functionName === "getUserOrders" || call.functionName === "getActiveDeliveryDates") { + if (call.functionName === "getUserOrders" || call.functionName === "getActiveExpirationDates") { return listResult; } throw new Error(`unexpected readContract call: ${call.functionName}`); @@ -58,9 +58,9 @@ function makeReadHandler(marketPrice: bigint, listResult: readonly unknown[]) { } describe("futures venue: marketLabel", () => { - it("renders deliveryAt as an ISO date prefix", () => { + it("renders expirationAt as an ISO date prefix", () => { const venue = new FuturesVenue(makeChainStub({}), makeConfigStub(), silentLogger); - const id = deliveryAtMarketId(DELIVERY_AT); + const id = expirationAtMarketId(DELIVERY_AT); assert.equal(venue.marketLabel(id), "futures 2025-08-28"); }); }); @@ -81,7 +81,7 @@ describe("futures venue: readOpenOrders", () => { assert.equal(multicallCount, 0, "no multicall when no orders"); }); - it("hydrates each order's deliveryAt as its marketId", async () => { + it("hydrates each order's expirationAt as its marketId", async () => { const orderIds: Hex[] = [ "0x000000000000000000000000000000000000000000000000000000000000000a", "0x000000000000000000000000000000000000000000000000000000000000000b", @@ -92,8 +92,8 @@ describe("futures venue: readOpenOrders", () => { assert.equal(calls.length, 2); for (const c of calls) assert.equal(c.functionName, "getOrder"); return [ - { participant: BUYER, deliveryAt: DELIVERY_AT, price: 50n, quantity: 1n }, - { participant: BUYER, deliveryAt: DELIVERY_AT + 86_400n, price: 60n, quantity: -1n }, + { participant: BUYER, expirationAt: DELIVERY_AT, price: 50n, quantity: 1n }, + { participant: BUYER, expirationAt: DELIVERY_AT + 86_400n, price: 60n, quantity: -1n }, ]; }, }); @@ -101,13 +101,13 @@ describe("futures venue: readOpenOrders", () => { const orders = await venue.readOpenOrders(BUYER); assert.equal(orders.length, 2); assert.equal(orders[0]?.id, orderIds[0]); - assert.equal(orders[0]?.marketId, deliveryAtMarketId(DELIVERY_AT)); - assert.equal(orders[1]?.marketId, deliveryAtMarketId(DELIVERY_AT + 86_400n)); + assert.equal(orders[0]?.marketId, expirationAtMarketId(DELIVERY_AT)); + assert.equal(orders[1]?.marketId, expirationAtMarketId(DELIVERY_AT + 86_400n)); }); }); describe("futures venue: readPositions", () => { - it("returns empty when getActiveDeliveryDates is empty", async () => { + it("returns empty when getActiveExpirationDates is empty", async () => { const chain = makeChainStub({ readContract: makeReadHandler(100n, []), multicall: () => [], @@ -133,7 +133,7 @@ describe("futures venue: readPositions", () => { assert.ok(pos); assert.equal(pos.unrealizedLoss, entry - marketPrice); assert.equal(pos.notional, entry); - assert.equal(pos.marketId, deliveryAtMarketId(DELIVERY_AT)); + assert.equal(pos.marketId, expirationAtMarketId(DELIVERY_AT)); }); it("computes short-side underwater PnL when market rises above entry", async () => { diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index 50d2619..160018e 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -80,7 +80,7 @@ function makeChainStub(opts: { } describe("futures venue: reduceToTarget", () => { - it("sizes a closeQty and submits liquidatePositions(deliveryAts, closeQtys)", async () => { + it("sizes a closeQty and submits liquidatePositions(expirationAts, closeQtys)", async () => { let simulated: ReadCall | undefined; const chain = makeChainStub({ balance: 136_000_000n, @@ -96,14 +96,14 @@ describe("futures venue: reduceToTarget", () => { assert.ok(simulated, "should simulate a liquidatePositions call"); assert.equal(simulated?.functionName, "liquidatePositions"); - const [participant, deliveryAts, closeQtys] = simulated?.args as [ + const [participant, expirationAts, closeQtys] = simulated?.args as [ Address, bigint[], bigint[], ]; assert.equal(participant, USER); - assert.equal(deliveryAts.length, 1); - assert.equal(deliveryAts[0], EXPIRY); + assert.equal(expirationAts.length, 1); + assert.equal(expirationAts[0], EXPIRY); assert.ok(closeQtys[0]! > 0n && closeQtys[0]! < 12n, "strict subset of contracts"); assert.ok("feeEarned" in outcome && outcome.positionsClosed === Number(closeQtys[0])); }); @@ -133,11 +133,11 @@ describe("futures venue: reduceToTarget", () => { } if (fns[0] === "getUserPosition") { return contracts.map((c) => { - const deliveryAt = c.args?.[1] as bigint; + const expirationAt = c.args?.[1] as bigint; return { netQuantity: 4n, netEntryValue: 4n * 40_000_000n, - _deliveryAt: deliveryAt, + _expirationAt: expirationAt, }; }); } @@ -153,8 +153,8 @@ describe("futures venue: reduceToTarget", () => { const venue = new FuturesVenue(chain, makeConfigStub(true, 2), silentLogger); const outcome = await venue.reduceToTarget(USER); assert.ok(simulated); - const [, deliveryAts] = simulated?.args as [Address, bigint[], bigint[]]; - assert.equal(deliveryAts.length, 2, "capped to 2 expiry legs"); + const [, expirationAts] = simulated?.args as [Address, bigint[], bigint[]]; + assert.equal(expirationAts.length, 2, "capped to 2 expiry legs"); assert.ok("feeEarned" in outcome); }); diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 1cac2ab..2488d84 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -218,7 +218,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable." + "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.expirationAt is unavailable." }, "minSpreadBps": { "anyOf": [ diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 8b1ddc9..1108a54 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -508,7 +508,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable." + "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.expirationAt is unavailable." }, "minSpreadBps": { "anyOf": [ diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 4627a3d..c8074df 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -7,8 +7,8 @@ import type { import { FuturesAbi } from "futures-contracts/abi/Futures"; /** Instrument id for a futures expiry, e.g. `futures:1893456000`. */ -export function futuresInstrumentId(deliveryDate: bigint): string { - return `futures:${deliveryDate.toString()}`; +export function futuresInstrumentId(expirationAt: bigint): string { + return `futures:${expirationAt.toString()}`; } type FuturesLog = Log< @@ -64,13 +64,13 @@ export class FuturesVenueEvents implements VenueEvents { export function decodeEvent(log: FuturesLog): VenueEvent | null { switch (log.eventName) { case "OrderCreated": { - const { orderId, participant, price, quantity, deliveryAt } = log.args; + const { orderId, participant, price, quantity, expirationAt } = log.args; if ( !orderId || !participant || price === undefined || quantity === undefined || - deliveryAt === undefined + expirationAt === undefined ) return null; const absQty = quantity < 0n ? -quantity : quantity; @@ -82,8 +82,8 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { price, side: quantity > 0n ? "buy" : "sell", size: absQty, - instrumentId: futuresInstrumentId(deliveryAt), - deliveryDate: deliveryAt, + instrumentId: futuresInstrumentId(expirationAt), + expirationAt: expirationAt, }; } case "OrderUpdated": { @@ -106,26 +106,26 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { return { type: "order-cancelled", orderId }; } case "OrderMatched": { - const { maker, taker, deliveryAt } = log.args; - if (!maker || !taker || deliveryAt === undefined) return null; + const { maker, taker, expirationAt } = log.args; + if (!maker || !taker || expirationAt === undefined) return null; // Broadcast position-changed for both sides; inventory resyncs via getUserPosition. return { type: "position-changed", participant: maker, - instrumentId: futuresInstrumentId(deliveryAt), + instrumentId: futuresInstrumentId(expirationAt), }; } case "PositionLiquidated": case "PositionSettled": { - const { user, deliveryAt } = log.args as { + const { user, expirationAt } = log.args as { user?: `0x${string}`; - deliveryAt?: bigint; + expirationAt?: bigint; }; - if (!user || deliveryAt === undefined) return null; + if (!user || expirationAt === undefined) return null; return { type: "position-changed", participant: user, - instrumentId: futuresInstrumentId(deliveryAt), + instrumentId: futuresInstrumentId(expirationAt), }; } default: diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 670ad16..1df901c 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -23,7 +23,7 @@ export { futuresInstrumentId } from "./events.ts"; /** * One futures market = one delivery date (expiry). The venue creates one * adapter per selected expiry; each owns its own book snapshot, own-order - * cache, and order encoding, all scoped to `deliveryDate`. + * cache, and order encoding, all scoped to `expirationAt`. * * Position and margin reads are per-expiry (client-side), while the shared * portfolio collateral/IM/MM lives on the venue's `CollateralAccount`. @@ -33,19 +33,19 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { readonly venue: FuturesVenueAdapter; readonly book: FuturesBook; readonly ownOrders: FuturesOwnOrders; - readonly deliveryDate: bigint; + readonly expirationAt: bigint; private tickCache: bigint | null = null; private marginPercentCache: bigint | null = null; - constructor(venue: FuturesVenueAdapter, deliveryDate: bigint, logger: pino.Logger) { + constructor(venue: FuturesVenueAdapter, expirationAt: bigint, logger: pino.Logger) { this.venue = venue; - this.deliveryDate = deliveryDate; - this.id = futuresInstrumentId(deliveryDate); + this.expirationAt = expirationAt; + this.id = futuresInstrumentId(expirationAt); this.book = new FuturesBook(this, venue.readBatchSize); this.ownOrders = new FuturesOwnOrders( venue, - deliveryDate, + expirationAt, logger.child({ instrument: this.id }), venue.readBatchSize, ); @@ -63,7 +63,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: FuturesAbi, functionName: "getUserPosition", - args: [this.venue.wallet.account.address, this.deliveryDate], + args: [this.venue.wallet.account.address, this.expirationAt], }); const netQuantity = pos.netQuantity; if (netQuantity === 0n) { @@ -79,7 +79,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { const { marginPct } = await this.venue.getMarginInputs(); this.marginPercentCache = marginPct; return { - deliveryDate: Number(this.deliveryDate), + expirationAt: Number(this.expirationAt), }; } @@ -92,7 +92,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { return encodeFunctionData({ abi: FuturesAbi, functionName: "createOrder", - args: [intent.price, this.deliveryDate, signed], + args: [intent.price, this.expirationAt, signed], }); } @@ -225,8 +225,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: FuturesAbi, functionName: "createOrder", - // Futures 3.0: createOrder(price, deliveryAt, signedQuantity) - args: [1_000_000n, this.deliveryDate, 1n], + // Futures 3.0: createOrder(price, expirationAt, signedQuantity) + args: [1_000_000n, this.expirationAt, 1n], account, }); } catch { @@ -262,7 +262,7 @@ class FuturesBook implements BookSource { async snapshot(opts: { depth?: number } = {}): Promise { const v = this.inst.venue; - const dd = this.inst.deliveryDate; + const dd = this.inst.expirationAt; const depth = BigInt(opts.depth ?? 200); const [bidPrices, askPrices] = await v.publicClient.multicall({ diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 60fcbf3..321ca56 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -16,10 +16,10 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; * * 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 `deliveryDate`: + * instrument's `expirationAt`: * * 1. `bootstrap()` reads `getUserOrders(wallet)` + `getOrder(id)` and - * caches the orders whose `deliveryAt === deliveryDate`. + * caches the orders whose `expirationAt === expirationAt`. * 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 — @@ -33,20 +33,20 @@ export class FuturesOwnOrders implements OwnOrderSource { private bootstrapped = false; private readonly venue: FuturesVenueAdapter; - private readonly deliveryDate: bigint; + private readonly expirationAt: bigint; private readonly instrumentId: string; private readonly logger: pino.Logger; private readonly readBatchSize: number; constructor( venue: FuturesVenueAdapter, - deliveryDate: bigint, + expirationAt: bigint, logger: pino.Logger, readBatchSize: number, ) { this.venue = venue; - this.deliveryDate = deliveryDate; - this.instrumentId = futuresInstrumentId(deliveryDate); + this.expirationAt = expirationAt; + this.instrumentId = futuresInstrumentId(expirationAt); this.logger = logger.child({ component: "futures-own-orders" }); this.readBatchSize = readBatchSize; } @@ -107,11 +107,11 @@ export class FuturesOwnOrders implements OwnOrderSource { participant: string; price: bigint; quantity: bigint; - deliveryAt: bigint; + expirationAt: bigint; }; if (!o.participant || o.participant === ZERO_ADDRESS) continue; // Keep only orders belonging to this expiry. - if (o.deliveryAt !== this.deliveryDate) continue; + if (o.expirationAt !== this.expirationAt) continue; if (o.quantity === 0n) continue; const absQty = o.quantity < 0n ? -o.quantity : o.quantity; this.cache.set(orderIds[i], { @@ -125,7 +125,7 @@ export class FuturesOwnOrders implements OwnOrderSource { this.bootstrapped = true; this.logger.info( - { orders: this.cache.size, deliveryDate: this.deliveryDate.toString() }, + { orders: this.cache.size, expirationAt: this.expirationAt.toString() }, "futures own-orders bootstrapped", ); } diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 52d0f96..5850653 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -24,7 +24,7 @@ import { FuturesVenueEvents } from "./events.ts"; /** * How the venue picks which delivery dates to quote out of the rolling window - * returned by `getDeliveryDates()` (ordered nearest-first). + * returned by `getExpirationDates()` (ordered nearest-first). * * - `nearest`: the first `count` dates (count=1 reproduces the legacy MVP). * - `indices`: explicit relative offsets into the window (0 = nearest). @@ -86,9 +86,9 @@ export class FuturesVenueAdapter implements VenueAdapter { readonly readBatchSize: number; readonly writeBatchSize: number; private readonly marketSelection: FuturesMarketSelection; - /** deliveryDate → instrument, memoized so each expiry has one adapter. */ + /** expirationAt → instrument, memoized so each expiry has one adapter. */ private readonly instruments = new Map(); - /** deliveryDates currently selected (as strings), from the last resolve. */ + /** expirationAts currently selected (as strings), from the last resolve. */ private activeKeys: string[] = []; private vaultAddressCache: `0x${string}` | null = null; @@ -157,7 +157,7 @@ export class FuturesVenueAdapter implements VenueAdapter { /** Nearest-expiry instrument. Back-compat / single-market entrypoint. */ async getInstrument(): Promise { - const dates = await this.readDeliveryDates(); + const dates = await this.readExpirationAts(); if (dates.length === 0) throw new Error("futures contract returned no delivery dates"); return this.instrumentFor(dates[0]); } @@ -175,7 +175,7 @@ export class FuturesVenueAdapter implements VenueAdapter { * down matured ones without disturbing the survivors. */ async resolveMarkets(): Promise { - const dates = await this.readDeliveryDates(); + const dates = await this.readExpirationAts(); const selected = this.selectDates(dates); const selectedKeys = selected.map((d) => d.toString()); @@ -201,9 +201,9 @@ export class FuturesVenueAdapter implements VenueAdapter { if (added.length > 0 || dropped.length > 0) { this.logger.info( { - active: active.map((i) => i.deliveryDate.toString()), - added: added.map((i) => i.deliveryDate.toString()), - dropped: dropped.map((i) => i.deliveryDate.toString()), + active: active.map((i) => i.expirationAt.toString()), + added: added.map((i) => i.expirationAt.toString()), + dropped: dropped.map((i) => i.expirationAt.toString()), }, "futures markets resolved", ); @@ -211,21 +211,21 @@ export class FuturesVenueAdapter implements VenueAdapter { return { active, added, dropped }; } - private instrumentFor(deliveryDate: bigint): FuturesInstrumentAdapter { - const key = deliveryDate.toString(); + private instrumentFor(expirationAt: bigint): FuturesInstrumentAdapter { + const key = expirationAt.toString(); let inst = this.instruments.get(key); if (!inst) { - inst = new FuturesInstrumentAdapter(this, deliveryDate, this.logger); + inst = new FuturesInstrumentAdapter(this, expirationAt, this.logger); this.instruments.set(key, inst); } return inst; } - private async readDeliveryDates(): Promise { + private async readExpirationAts(): Promise { const dates = await this.publicClient.readContract({ address: this.address, abi: FuturesAbi, - functionName: "getDeliveryDates", + functionName: "getExpirationDates", }); return [...dates]; } @@ -387,8 +387,8 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { ] as MarginReadPlan["shared"]; const venue = [ - { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesOrderMargin", args: [owner] }, - { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesUnrealizedPnl", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getOrderMargin", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getUnrealizedPnl", args: [owner] }, ] as MarginReadPlan["venue"]; const decode = (results: readonly unknown[]): CollateralSnapshot => { diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 9072a45..5328773 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -68,7 +68,7 @@ export const futuresPricingSchema = Type.Object( marginCallTimeSec: Type.Number({ minimum: 0, description: - "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable.", + "Seconds. Fallback time-to-margin-call when InstrumentContext.expirationAt is unavailable.", }), minSpreadBps: Type.Number({ minimum: 0, diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 162a2f9..6ca1a82 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -175,7 +175,7 @@ export function isBatchableCollateralAccount( export interface InstrumentContext { /** Unix seconds of delivery / expiry, if any. */ - deliveryDate?: number; + expirationAt?: number; /** Strike price (options). */ strike?: bigint; /** Call vs put (options). */ @@ -247,7 +247,7 @@ export type VenueEvent = size: bigint; instrumentId?: string; /** Futures expiry (unix seconds) the order belongs to; undefined for perps. */ - deliveryDate?: bigint; + expirationAt?: bigint; } | { type: "order-updated"; diff --git a/market-maker/src/core/pricing/reservationPrice.ts b/market-maker/src/core/pricing/reservationPrice.ts index b3da833..5c99ef3 100644 --- a/market-maker/src/core/pricing/reservationPrice.ts +++ b/market-maker/src/core/pricing/reservationPrice.ts @@ -16,7 +16,7 @@ * ask = r · (1 + half_spread_bps / 1e4) * * q = netQuantity / QUANTITY_SCALE (signed, in "contracts") - * T = max(0, deliveryDate − now) (seconds, fallback marginCallTimeSeconds) + * T = max(0, expirationAt − now) (seconds, fallback marginCallTimeSeconds) * H = vol horizon (seconds; defaults to pollInterval) * σ_s = OracleTracker.volatilityPerSecond (units s^-1/2) * @@ -55,7 +55,7 @@ * Section 3.2 derives r = S − q · γ · σ² · T and shows half-spread widens * with γ and σ; the "min_spread floor" used here is a practitioner add-on * to handle gas costs and exchange minimums that A-S abstracts away. - * - For futures, T is bounded above by deliveryDate (margin-call point); + * - For futures, T is bounded above by expirationAt (margin-call point); * after delivery the position settles and there's no more inventory risk. */ @@ -74,7 +74,7 @@ const VOL_HORIZON_PRECISION_BITS = 48; export interface ReservationPriceConfig { /** Avellaneda–Stoikov risk aversion γ. */ riskAversion: number; - /** Fallback remaining time (seconds) when InstrumentContext.deliveryDate is absent. */ + /** Fallback remaining time (seconds) when InstrumentContext.expirationAt is absent. */ marginCallTimeSeconds: number; /** Floor full-spread in basis points; one-side half-spread is half this. */ minSpreadBps: number; @@ -103,8 +103,8 @@ export function computeReservationMidQuote(opts: { const sigma2 = sigma.mul(sigma); const gamma = fromNumber(cfg.riskAversion); - const remainingSeconds: Fraction = context.deliveryDate !== undefined - ? fromNumber(Math.max(0, context.deliveryDate - nowMs / 1000)) + const remainingSeconds: Fraction = context.expirationAt !== undefined + ? fromNumber(Math.max(0, context.expirationAt - nowMs / 1000)) : fromNumber(cfg.marginCallTimeSeconds); const q = new Fraction(inventory.netQuantity, QUANTITY_SCALE); diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index e5fb467..cc7a0f5 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -88,7 +88,7 @@ export class Quoter { this.tick = await this.instrument.book.tick(); this.context = await this.instrument.getContext(); this.logger.info( - { tick: this.tick.toString(), deliveryDate: this.context.deliveryDate }, + { tick: this.tick.toString(), expirationAt: this.context.expirationAt }, "quoter initialized", ); } diff --git a/market-maker/tests/core/pricing/reservationPrice.test.ts b/market-maker/tests/core/pricing/reservationPrice.test.ts index 6561c77..2923320 100644 --- a/market-maker/tests/core/pricing/reservationPrice.test.ts +++ b/market-maker/tests/core/pricing/reservationPrice.test.ts @@ -96,10 +96,10 @@ describe("computeReservationMidQuote", () => { assert.ok(shortInv.askMid >= noInv.askMid, "short inventory should push ask mid up or equal"); }); - it("uses deliveryDate from context when provided", () => { + it("uses expirationAt from context when provided", () => { const nowMs = Date.now(); const futureDelivery = Math.floor(nowMs / 1000) + 7200; // 2 hours from now - const context: InstrumentContext = { deliveryDate: futureDelivery }; + const context: InstrumentContext = { expirationAt: futureDelivery }; const { bidMid, askMid } = computeReservationMidQuote({ oracle: makeOracle(1_000_000_000n), @@ -115,7 +115,7 @@ describe("computeReservationMidQuote", () => { assert.ok(bidMid > 0n); }); - it("expired deliveryDate (T=0) produces no inventory adjustment", () => { + it("expired expirationAt (T=0) produces no inventory adjustment", () => { const nowMs = Date.now(); const pastDelivery = Math.floor(nowMs / 1000) - 100; // already expired const bigInventory = makeInventory(100_000_000n); @@ -125,7 +125,7 @@ describe("computeReservationMidQuote", () => { oracle: makeOracle(1_000_000_000n, new Fraction(1n, 100n)), gas: makeGas(), inventory: bigInventory, - context: { deliveryDate: pastDelivery }, + context: { expirationAt: pastDelivery }, cfg, tick: TICK, volHorizonSec: HORIZON_SEC, From 3cddb2f7a202b4a2216b62147a2321be9252e611 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:00:12 +0200 Subject: [PATCH 093/180] fix(mm): read futures book via getOrderBookPrices Align with perps book snapshot API and the Futures ABI where getOrderBookPrices(expirationAt, depth) matches getBid/AskPrices. Also retarget futures deps/CI pin to the expirationAt tip. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 2 +- keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 10 +++--- market-maker/package.json | 2 +- market-maker/pnpm-lock.yaml | 34 +++++++++++-------- .../src/adapters/futures/instrument.ts | 28 +++++---------- 6 files changed, 37 insertions(+), 41 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 64d8ee5..72b3f5e 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -76,7 +76,7 @@ jobs: repository: Lumerin-protocol/futures-marketplace # Futures 3.0 aggregate positions (PR Lumerin-protocol/futures-marketplace#215). # After that merges to dev, retarget this pin to the merge commit / dev tip. - ref: d5cf8b9154276efdbe583c5be4c81eb0eedbaadc + ref: 6c14a42b37dcb776e4925777e7a843749d5386a4 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/package.json b/keeper/package.json index 07faaf5..9a93177 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#6215556a3419e2ff1e5b19567fc5f2b9cbeb080c&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index fed2e7b..7af1634 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#6215556a3419e2ff1e5b19567fc5f2b9cbeb080c&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,8 +166,8 @@ 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/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-VDQ9tU3JO0fYzgpy1KSe37ym2Kp69AhIJjg71TWou99LPp88wpS7Eza6Eu2pFn6YVOjc1QXHW0m17MY5I/LqoQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4} version: 0.0.0 help-me@5.0.0: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6215556a3419e2ff1e5b19567fc5f2b9cbeb080c#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/market-maker/package.json b/market-maker/package.json index 3677c95..1dca0ae 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -57,4 +57,4 @@ "secp256k1@4.0.4" ] } -} \ No newline at end of file +} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index c3b2e5c..2a2cb73 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#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 @@ -453,6 +453,10 @@ 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} @@ -1245,6 +1249,10 @@ packages: resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456} + version: 1.0.0 + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1542,8 +1550,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4} version: 1.0.0 engines: {node: 24.x} @@ -1909,10 +1917,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3477,6 +3481,8 @@ 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': @@ -4480,6 +4486,8 @@ snapshots: collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4564,13 +4572,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/multicall3': https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@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/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4844,7 +4852,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/d5cf8b9154276efdbe583c5be4c81eb0eedbaadc#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#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 @@ -4853,9 +4861,9 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -5274,8 +5282,6 @@ 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: {} diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 1df901c..964136b 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -262,25 +262,15 @@ class FuturesBook implements BookSource { async snapshot(opts: { depth?: number } = {}): Promise { const v = this.inst.venue; - const dd = this.inst.expirationAt; + const expirationAt = this.inst.expirationAt; const depth = BigInt(opts.depth ?? 200); - const [bidPrices, askPrices] = await v.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: v.address, - abi: FuturesAbi, - functionName: "getBidPrices", - args: [dd, depth], - }, - { - address: v.address, - abi: FuturesAbi, - functionName: "getAskPrices", - args: [dd, depth], - }, - ], + // Same shape as perps `getOrderBookPrices(depth)`, with expirationAt first. + const [bidPrices, askPrices] = await v.publicClient.readContract({ + address: v.address, + abi: FuturesAbi, + functionName: "getOrderBookPrices", + args: [expirationAt, depth], }); if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; @@ -290,13 +280,13 @@ class FuturesBook implements BookSource { address: v.address, abi: FuturesAbi, functionName: "getQuantityAtPrice" as const, - args: [dd, p, true] as const, + args: [expirationAt, p, true] as const, })), ...askPrices.map((p) => ({ address: v.address, abi: FuturesAbi, functionName: "getQuantityAtPrice" as const, - args: [dd, p, false] as const, + args: [expirationAt, p, false] as const, })), ]; From fdb1d3c38cdf253ef381df04c34dc8f9d0f079d3 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:08:25 +0200 Subject: [PATCH 094/180] fix(ci): bump PME VERSION and drop fragile futures ABI patch PortfolioMarginEngine is 1.0.1 after the Futures view rename. Keeper now resolves futures-marketplace-abi via upstream abi/package.json (no patch). Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 2 +- contracts/contracts/PortfolioMarginEngine.sol | 2 +- keeper/patches/futures-marketplace-abi@.patch | 13 ------ keeper/pnpm-lock.yaml | 9 ++--- keeper/pnpm-workspace.yaml | 1 - market-maker/pnpm-lock.yaml | 40 +++++++++---------- 6 files changed, 26 insertions(+), 41 deletions(-) delete mode 100644 keeper/patches/futures-marketplace-abi@.patch diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 72b3f5e..83cf4cf 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -76,7 +76,7 @@ jobs: repository: Lumerin-protocol/futures-marketplace # Futures 3.0 aggregate positions (PR Lumerin-protocol/futures-marketplace#215). # After that merges to dev, retarget this pin to the merge commit / dev tip. - ref: 6c14a42b37dcb776e4925777e7a843749d5386a4 + ref: 20fcd524629f87c352161377a7b50ac263b92e12 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 6318d89..239be7c 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -29,7 +29,7 @@ contract PortfolioMarginEngine is OwnableUpgradeable { uint256 private constant WAD = 1e18; - string public constant VERSION = "1.0.0"; + string public constant VERSION = "1.0.1"; // ── Storage ───────────────────────────────────────────────────────────── diff --git a/keeper/patches/futures-marketplace-abi@.patch b/keeper/patches/futures-marketplace-abi@.patch deleted file mode 100644 index e298abb..0000000 --- a/keeper/patches/futures-marketplace-abi@.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/package.json b/package.json -new file mode 100644 -index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 ---- /dev/null -+++ b/package.json -@@ -0,0 +1,7 @@ -+{ -+ "name": "futures-marketplace-abi", -+ "type": "module", -+ "exports": { -+ "./*": "./*" -+ } -+} diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 7af1634..14052c0 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -7,7 +7,6 @@ settings: patchedDependencies: collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d - futures-marketplace-abi: 834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a importers: @@ -24,7 +23,7 @@ importers: version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -166,8 +165,8 @@ 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/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-VDQ9tU3JO0fYzgpy1KSe37ym2Kp69AhIJjg71TWou99LPp88wpS7Eza6Eu2pFn6YVOjc1QXHW0m17MY5I/LqoQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-csFZ1epYxzjKDGBIAkXPU58PLazN9ZLNBzbD9gPmkTN+BQPRXbNFo9SqTM03tqYLtIiyf4wO2BajXi6+L2Ep6Q==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} version: 0.0.0 help-me@5.0.0: @@ -364,7 +363,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index 7064f91..ec4c4f0 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -3,4 +3,3 @@ blockExoticSubdeps: false patchedDependencies: collateral-margin-abi: patches/collateral-margin-abi@.patch derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch - futures-marketplace-abi: patches/futures-marketplace-abi@.patch diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 2a2cb73..4f4a220 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#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 @@ -453,10 +453,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} @@ -1245,12 +1241,12 @@ packages: version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611} version: 1.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 color-convert@2.0.1: @@ -1550,8 +1546,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} version: 1.0.0 engines: {node: 24.x} @@ -1917,6 +1913,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: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3481,8 +3481,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': @@ -4484,9 +4482,9 @@ snapshots: - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611: {} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} color-convert@2.0.1: dependencies: @@ -4572,13 +4570,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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 '@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/a7f89b8fd167655d1157568230bc6779ce37d512 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' @@ -4852,7 +4850,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/6c14a42b37dcb776e4925777e7a843749d5386a4#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#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 @@ -4861,9 +4859,9 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/f952ac6a4d4b765e17f11d25f000f82a8d2db456 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - 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 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -5282,6 +5280,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: {} From 62901e9ab4e0a1cc3d7374ba6706087a2113519f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:13:56 +0200 Subject: [PATCH 095/180] fix(keeper): restore path in futures ABI lockfile resolution pnpm dropped path:/contracts/abi when writing integrity-only resolution, so cold CI installs unpacked the whole futures-marketplace repo and TypeCheck could not resolve Futures.ts. Pin the dep commit and verify the abi package layout after install. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 23 +++++++++++++++++++++++ keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 83cf4cf..e2b9dd0 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -51,6 +51,29 @@ 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 + working-directory: ./keeper + run: | + set -euo pipefail + pkg="node_modules/futures-marketplace-abi" + test -f "$pkg/Futures.ts" + test -f "$pkg/package.json" + 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 + - name: TypeCheck working-directory: ./keeper run: pnpm typecheck diff --git a/keeper/package.json b/keeper/package.json index 9a93177..c9f2fa0 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#20fcd524629f87c352161377a7b50ac263b92e12&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 14052c0..f693d9d 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#20fcd524629f87c352161377a7b50ac263b92e12&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi pino: specifier: ^10.3.1 @@ -166,7 +166,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-csFZ1epYxzjKDGBIAkXPU58PLazN9ZLNBzbD9gPmkTN+BQPRXbNFo9SqTM03tqYLtIiyf4wO2BajXi6+L2Ep6Q==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} version: 0.0.0 help-me@5.0.0: From fe34a2bd35eb12333a57dac83f65807bfbd2956a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:42:15 +0200 Subject: [PATCH 096/180] test(keeper): align getOrder mocks with Futures 3.1 Order shape Use signed quantity fields instead of the removed isBuy/destURL layout. Co-authored-by: Cursor --- keeper/tests/runtime/outdatedOrderSweeper.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index 902a543..ebdb98d 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -114,12 +114,10 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { // reads `expirationAt` but include the other fields so tests // stay close to the real ABI. return { - isBuy: true, participant: order.participant, - destURL: "", - pricePerDay: 0n, + price: 0n, + quantity: 1n, expirationAt: order.expirationAt, - createdAt: 0n, }; }); }, @@ -474,12 +472,10 @@ describe("OutdatedOrderSweeper", () => { const order = orders.get(c.args[0] as Hex); if (order === undefined) throw new Error("missing"); return { - isBuy: true, participant: order.participant, - destURL: "", - pricePerDay: 0n, + price: 0n, + quantity: 1n, expirationAt: order.expirationAt, - createdAt: 0n, }; }); }, From 3c836e6a41adcaaaef157baff1b8178df74cd79c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:43:21 +0200 Subject: [PATCH 097/180] chore(deps): pin futures ABI to 3.1 Order unify tip Point keeper package/CI and refresh MM lockfile at 2e2c1b59. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 2 +- keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 10 +++++----- market-maker/pnpm-lock.yaml | 22 +++++++++++----------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index e2b9dd0..7d3516c 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -99,7 +99,7 @@ jobs: repository: Lumerin-protocol/futures-marketplace # Futures 3.0 aggregate positions (PR Lumerin-protocol/futures-marketplace#215). # After that merges to dev, retarget this pin to the merge commit / dev tip. - ref: 20fcd524629f87c352161377a7b50ac263b92e12 + ref: 2e2c1b599eec37f2f27973e78bafaf0870e1df7a path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/package.json b/keeper/package.json index c9f2fa0..eb1d8b2 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -22,7 +22,7 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#20fcd524629f87c352161377a7b50ac263b92e12&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#2e2c1b599eec37f2f27973e78bafaf0870e1df7a&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index f693d9d..1b4f557 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#20fcd524629f87c352161377a7b50ac263b92e12&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#2e2c1b599eec37f2f27973e78bafaf0870e1df7a&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -165,8 +165,8 @@ 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/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-LScXrIs5FTsgQtgqYn7AcACUYdP/qtWPuapRbWYLZxjE5tFS/6jWfxPKBc5TNflZsBPifvSG2til0Y2fxazqXQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} version: 0.0.0 help-me@5.0.0: @@ -363,7 +363,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 4f4a220..61edf62 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.3.4 futures-contracts: specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#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 @@ -1241,14 +1241,14 @@ packages: version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611} - version: 1.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a} + version: 1.0.0 + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1546,8 +1546,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} version: 1.0.0 engines: {node: 24.x} @@ -4482,10 +4482,10 @@ snapshots: - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611: {} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4850,7 +4850,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/20fcd524629f87c352161377a7b50ac263b92e12#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#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 @@ -4859,7 +4859,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/3cddb2f7a202b4a2216b62147a2321be9252e611 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 From ce0b494f3050b96deb4a400f099c53086f2273b9 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 18:43:51 +0200 Subject: [PATCH 098/180] fix(keeper): keep path:/contracts/abi in futures lockfile resolution pnpm update dropped the path field; restore it so cold CI installs the abi package, not the whole repo. Co-authored-by: Cursor --- keeper/pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 1b4f557..56b6665 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -166,7 +166,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-LScXrIs5FTsgQtgqYn7AcACUYdP/qtWPuapRbWYLZxjE5tFS/6jWfxPKBc5TNflZsBPifvSG2til0Y2fxazqXQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} version: 0.0.0 help-me@5.0.0: From 703c07a1b22fddad83c58e50815b9e63a8064a6c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 20 Jul 2026 19:47:29 +0200 Subject: [PATCH 099/180] feat(mm): drop exact matching mode for limit LOB stale policy Futures now walks a limit book like perps; keep better-than-grid quotes and remove MatchingMode branching from OrderExecutor. Co-authored-by: Cursor --- market-maker/README.md | 14 +-- market-maker/src/adapters/futures/index.ts | 6 +- .../src/adapters/futures/instrument.ts | 2 - market-maker/src/adapters/perps/index.ts | 2 - market-maker/src/adapters/perps/instrument.ts | 2 - market-maker/src/apps/futures/config.ts | 7 +- market-maker/src/core/adapter.ts | 11 +- market-maker/src/core/orderExecutor.ts | 73 ++--------- .../src/core/pricing/reservationPrice.ts | 6 +- market-maker/src/core/quoter.ts | 5 +- .../src/core/sizing/geometricTaper.ts | 6 +- market-maker/tests/core/oracleTracker.test.ts | 1 - market-maker/tests/core/orderExecutor.test.ts | 118 ++++++++---------- 13 files changed, 76 insertions(+), 177 deletions(-) diff --git a/market-maker/README.md b/market-maker/README.md index 426dbe5..c080962 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -109,17 +109,11 @@ graph LR `PortfolioMarginEngine.canPlaceOrder(additionalIM)` so we never submit orders the vault can't margin -### Matching modes +### Stale-order policy -- **Perps** (`limit`): contract matches at any price strictly better - than the resting limit. Outdated own orders that are still better - than the new desired price are kept in place. -- **Futures** (`exact`): contract matches at the exact resting price. - Any deviation in either direction means the order has to be - cancelled and re-placed. - -The shared `OrderExecutor` branches on the adapter's `matchingMode` -when deciding whether an existing order is still good. +Both venues use limit LOB matching. `OrderExecutor` keeps own orders +that are still at-least-as-aggressive as the worst desired bid/ask +and cancels worse ones. ### Graceful shutdown diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index 6fe911a..ab00c2c 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -15,11 +15,7 @@ export interface CreateFuturesVenueOpts { logger: pino.Logger; } -/** - * Construct a futures venue adapter. Static wiring — no registry lookup. - * - * matchingMode = "exact" — fills only happen when prices coincide exactly. - */ +/** Construct a futures venue adapter. Static wiring — no registry lookup. */ export async function createFuturesVenue( opts: CreateFuturesVenueOpts, ): Promise { diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 964136b..11c2528 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -8,7 +8,6 @@ import type { ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, - MatchingMode, OrderBookSnapshot, OrderIntent, Position, @@ -248,7 +247,6 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { /** Per-expiry book source. Reads the ladders for this instrument's delivery date. */ class FuturesBook implements BookSource { - readonly matchingMode: MatchingMode = "exact"; private readonly inst: FuturesInstrumentAdapter; private readonly readBatchSize: number; constructor(inst: FuturesInstrumentAdapter, readBatchSize: number) { diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts index c101fe1..854a52d 100644 --- a/market-maker/src/adapters/perps/index.ts +++ b/market-maker/src/adapters/perps/index.ts @@ -23,8 +23,6 @@ export interface CreatePerpsVenueOpts { * * Caller is responsible for providing the wallet and Multicall3 address; * `WalletRegistry` and `createNetworkClients` from core handle both. - * - * matchingMode = "limit" — orders fill at any price better-or-equal. */ export async function createPerpsVenue( opts: CreatePerpsVenueOpts, diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index e1af7ca..7f2d98c 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -8,7 +8,6 @@ import type { ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, - MatchingMode, OrderBookSnapshot, OrderIntent, OwnOrder, @@ -248,7 +247,6 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } class PerpsBook implements BookSource { - readonly matchingMode: MatchingMode = "limit"; private readonly inst: PerpsInstrumentAdapter; constructor(inst: PerpsInstrumentAdapter) { this.inst = inst; diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 5328773..83ae998 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -25,10 +25,9 @@ import { ConfigError } from "../../core/errors.ts"; /** * Futures app config schema. * - * Pricing locked to "reservation-price" (Avellaneda–Stoikov) — that's the - * strategy that fits exact-match futures: the inventory shift on r is more - * useful than a symmetric spread because price levels are non-fungible - * (each is a separate fill opportunity). + * Pricing locked to "reservation-price" (Avellaneda–Stoikov) — inventory shift + * on r is more useful than a symmetric spread on multi-level futures books + * (each level is a distinct fill opportunity on the ladder). * * Sizing locked to "geometric-taper" so the front level (highest fill prob) * is the largest. taperRatio in (0, 1) is required. diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 6ca1a82..180c12f 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -184,18 +184,9 @@ export interface InstrumentContext { underlyingSpot?: bigint; } -// ─── Order book / matching mode ───────────────────────────────────────────── - -/** - * "limit" — orders match at any better-or-equal price (perps). - * "exact" — orders only match at the exact submitted price (futures). - * - * Drives stale-order detection in OrderExecutor. - */ -export type MatchingMode = "limit" | "exact"; +// ─── Order book ───────────────────────────────────────────────────────────── export interface BookSource { - readonly matchingMode: MatchingMode; /** Smallest price step on the venue. */ tick(): Promise; /** Snapshot of resting depth (best `depth` levels per side). */ diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 24f245d..1be9971 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -25,12 +25,9 @@ export interface OrderExecutorConfig { /** * Diff desired quotes vs the resting book; cancel + place via venue multicall. * - * Stale-order detection is matching-mode-aware: - * - "exact" (futures): a resting order is stale iff its price is not in the - * desired set (each level only matches at exactly its price). - * - "limit" (perps): a resting buy is stale iff its price < worst desired - * bid; a resting sell is stale iff its price > worst desired ask. Orders - * better-than-the-grid are kept (better priority + better price). + * Stale-order detection (limit LOB): a resting buy is stale iff its price < + * worst desired bid; a resting sell is stale iff its price > worst desired ask. + * Orders better-than-the-grid are kept (better priority + better price). */ export class OrderExecutor { readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; @@ -204,15 +201,8 @@ export class OrderExecutor { return false; } - // `ownOrders.size` counts individual resting orders. On exact-matching - // venues (futures) a single createOrder(qty=N) rests as N distinct orders, - // so the comparable "expected" is the qty-expanded total, not the level - // count — otherwise this fast-path is dead (actual is always ≫ levels) and - // the log is misleading. Limit venues (perps) rest one order per level. - const expectedCount = - this.instrument.book.matchingMode === "exact" - ? desired.reduce((sum, i) => sum + Number(i.size), 0) - : desired.length; + // One qty-bearing resting order per desired level (perps + futures LOB). + const expectedCount = desired.length; const actualCount = this.book.ownOrders.size; if (actualCount < expectedCount) { this.logger.debug( @@ -227,7 +217,7 @@ export class OrderExecutor { return true; } - if (this.hasStaleOrders(desired)) { + if (this.findStaleOrders(desired).length > 0) { this.logger.debug("requote triggered: stale orders at wrong prices"); return true; } @@ -246,24 +236,6 @@ export class OrderExecutor { return false; } - /** True when any own order sits at a price not in the desired set. */ - private hasStaleOrders(desired: OrderIntent[]): boolean { - const desiredPrices = new Map>(); - for (const i of desired) { - let set = desiredPrices.get(i.side); - if (!set) { - set = new Set(); - desiredPrices.set(i.side, set); - } - set.add(i.price); - } - for (const order of this.book.ownOrders.values()) { - const set = desiredPrices.get(order.side); - if (!set || !set.has(order.price)) return true; - } - return false; - } - private priceDriftTicks(): number { if (this.lastQuoteMidPrice === 0n) return Number.POSITIVE_INFINITY; const tick = this.quoter.getTick(); @@ -285,32 +257,10 @@ export class OrderExecutor { } /** - * Stale = should be cancelled. See class header for matching-mode rules. + * Stale = should be cancelled. Keep orders at-least-as-aggressive as the + * worst desired price for that side (higher bid / lower ask). */ private findStaleOrders(desired: OrderIntent[]): OwnOrder[] { - const mode = this.instrument.book.matchingMode; - if (mode === "exact") return this.findStaleOrdersExact(desired); - return this.findStaleOrdersLimit(desired); - } - - private findStaleOrdersExact(desired: OrderIntent[]): OwnOrder[] { - const desiredBidPrices = new Set(); - const desiredAskPrices = new Set(); - for (const i of desired) { - (i.side === "buy" ? desiredBidPrices : desiredAskPrices).add(i.price); - } - const stale: OwnOrder[] = []; - for (const order of this.book.ownOrders.values()) { - const set = order.side === "buy" ? desiredBidPrices : desiredAskPrices; - if (!set.has(order.price)) stale.push(order); - } - return stale; - } - - private findStaleOrdersLimit(desired: OrderIntent[]): OwnOrder[] { - // For limit-mode, keep any resting order that is at-least-as-aggressive as - // the worst desired price for that side. "Aggressive" means a higher price - // for buys and a lower price for sells. let worstDesiredBid: bigint | undefined; let worstDesiredAsk: bigint | undefined; for (const i of desired) { @@ -337,12 +287,7 @@ export class OrderExecutor { return stale; } - /** - * New orders = desired levels that are missing from the resting book at - * exactly the desired price (regardless of matching mode). Limit mode's - * "we have an even better resting order" case is covered by the deficit - * check returning 0 for that level, so we don't double-place. - */ + /** New orders = desired levels missing size at exactly the desired price. */ private findNewOrders(desired: OrderIntent[]): OrderIntent[] { const existing = this.aggregateOwnSizeByPriceSide(); const out: OrderIntent[] = []; diff --git a/market-maker/src/core/pricing/reservationPrice.ts b/market-maker/src/core/pricing/reservationPrice.ts index 5c99ef3..bed62a2 100644 --- a/market-maker/src/core/pricing/reservationPrice.ts +++ b/market-maker/src/core/pricing/reservationPrice.ts @@ -2,9 +2,9 @@ * # Reservation-price pricing (Avellaneda–Stoikov) * * Asymmetric quoter where the *mid* is shifted by inventory and the half-spread - * is widened by vol/gas. Used on futures where matching is "exact" — each - * level needs a distinct price to be useful, and the shift means the side we - * want to be hit gets a better price than the side we don't. + * is widened by vol/gas. Used on futures multi-level books — each level needs a + * distinct price, and the shift means the side we want to be hit gets a better + * price than the side we don't. * * ## Formulas * diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index cc7a0f5..d7fb589 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -31,9 +31,8 @@ export interface QuoterConfig { maxSkewTicks: number; /** * Spacing between successive quote levels, in ticks. App-defaulted per - * matching mode: futures (exact) wants narrow spacing (each level fills - * independently), perps (limit) wants wider spacing (deeper levels only - * fill conditional on shallower levels filling first). + * venue: futures wants narrow spacing (dense ladder), perps wants wider + * spacing (deeper levels only fill after shallower ones). */ levelSpacingTicks: number; /** diff --git a/market-maker/src/core/sizing/geometricTaper.ts b/market-maker/src/core/sizing/geometricTaper.ts index 4988f54..52bb47f 100644 --- a/market-maker/src/core/sizing/geometricTaper.ts +++ b/market-maker/src/core/sizing/geometricTaper.ts @@ -11,9 +11,9 @@ * { Q/2, Q/4, Q/8, ... } / (1 − 0.5^N) ≈ { Q/2, Q/4, Q/8, ... } * for large N. As ratio → 1 sizes flatten toward Q/N each. * - * Used on futures where matching is "exact" — every level needs a distinct - * fill probability profile, and the front level should be the largest - * because it has the only direct fill opportunity. + * Used on futures multi-level books — every level needs a distinct fill + * probability profile, and the front level should be the largest because it + * has the highest hit rate. * * ## Edge cases * diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index 54444d8..4a52a48 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -21,7 +21,6 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { id: "test", venue: {} as InstrumentAdapter["venue"], book: { - matchingMode: "limit" as const, tick: async () => 1n, snapshot: async () => ({ bids: [], asks: [] }), }, diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 0104afe..5b50f0d 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -8,7 +8,6 @@ import type { InstrumentAdapter, OrderIntent, OwnOrder, - MatchingMode, ExecuteOrdersIntent, } from "../../src/core/adapter.ts"; import type { Quoter } from "../../src/core/quoter.ts"; @@ -58,7 +57,7 @@ function makeDeps(overrides: Partial = {}): TestDeps { const deps: TestDeps = { instrument: { id: "test-instrument", - book: { matchingMode: "exact" as MatchingMode }, + book: {}, executeOrders: async (intent: ExecuteOrdersIntent) => { for (const c of intent.cancels) cancelledOrderIds.push(c.orderId); for (const p of intent.creates) placedIntents.push(p); @@ -138,22 +137,20 @@ function seedOrder( describe("OrderExecutor requote guards (regression)", () => { /** - * Bug: when all resting orders are at wrong prices (e.g. stale from a - * previous oracle level), `hasQuantityDeficit` returned false because no - * desired level had matching existing orders (`have === undefined`). - * A requote was never triggered and the book stayed shifted forever. + * When all resting orders are worse than the desired grid, quantity deficit + * alone used to miss the requote (`have === undefined` at desired prices). + * Stale detection must cancel them and place the grid. */ - it("requotes when all orders are at wrong prices (quantity-deficit fix)", async () => { + it("requotes when all orders are worse than the desired grid", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); - // Seed the book with stale orders at wrong prices (oracle was higher). - seedOrder(deps.book, 1, "buy", 99_000_000n, 1_000_000n); - seedOrder(deps.book, 2, "buy", 98_000_000n, 1_000_000n); + // Worse than desired bid@95 / ask@96. + seedOrder(deps.book, 1, "buy", 90_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "buy", 91_000_000n, 1_000_000n); seedOrder(deps.book, 3, "sell", 101_000_000n, 1_000_000n); seedOrder(deps.book, 4, "sell", 102_000_000n, 1_000_000n); - // Desired quotes are at the current (lower) oracle prices. const desired: OrderIntent[] = [ desiredBuy(95_000_000n), desiredSell(96_000_000n), @@ -161,34 +158,27 @@ describe("OrderExecutor requote guards (regression)", () => { await executor.reconcile(desired); - // All stale orders must be cancelled. assert.equal( deps.cancelledOrderIds.length, 4, - "all stale orders cancelled", + "all worse orders cancelled", ); - // Missing desired levels must be placed. assert.equal(deps.placedIntents.length, 2, "missing levels placed"); }); /** - * Bug: when stale orders at wrong prices coexist with correct orders at - * desired prices (e.g. cancels failed but creates succeeded on a prior - * reconciliation), the deficit check didn't fire (all desired levels - * have sufficient quantity), and the stale-orders guard was missing. - * The wrong-price orders persisted forever. + * Worse leftovers coexist with correct grid orders — cancel only the worse + * ones. Better-than-grid leftovers are kept (limit LOB policy). */ - it("requotes when stale orders coexist with correct ones (stale-orders guard)", async () => { + it("cancels worse leftovers while keeping the desired grid", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); - // Correct orders at the right prices. seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - // Stale orders at wrong prices (leftover from a previous oracle level - // whose cancels failed or were never submitted). - seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); + // Worse leftovers (cancels failed on a prior tick). + seedOrder(deps.book, 3, "buy", 90_000_000n, 1_000_000n); seedOrder(deps.book, 4, "sell", 101_000_000n, 1_000_000n); const desired: OrderIntent[] = [ @@ -198,9 +188,7 @@ describe("OrderExecutor requote guards (regression)", () => { await executor.reconcile(desired); - // Stale orders must be cancelled. - assert.equal(deps.cancelledOrderIds.length, 2, "stale orders cancelled"); - // Correct orders must survive (no deficit → no new placement at same prices). + assert.equal(deps.cancelledOrderIds.length, 2, "worse leftovers cancelled"); assert.equal( deps.placedIntents.length, 0, @@ -208,6 +196,21 @@ describe("OrderExecutor requote guards (regression)", () => { ); }); + it("keeps better-than-grid leftovers (does not cancel them as stale)", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid + seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask + + await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + + assert.equal(deps.cancelledOrderIds.length, 0, "better leftovers kept"); + assert.equal(deps.placedIntents.length, 0); + }); + /** * Sanity: when the book already matches the desired quotes exactly, * no reconciliation work should happen. @@ -231,46 +234,32 @@ describe("OrderExecutor requote guards (regression)", () => { }); }); -// ── exact-mode (futures) qty-expanded count deficit ──────────────────────── +// ── quantity deficit (qty-bearing orders) ────────────────────────────────── -describe("OrderExecutor exact-mode count deficit (qty-expanded)", () => { - /** - * On exact-matching venues a createOrder(qty=N) rests as N distinct orders, - * so `expectedCount` must be the qty-expanded total (Σ desired sizes), not - * the level count. A fully-provisioned multi-contract book must NOT churn. - */ - it("does not requote when a multi-contract book is fully provisioned", () => { - const deps = makeDeps(); // matchingMode defaults to "exact" +describe("OrderExecutor quantity deficit", () => { + it("does not requote when resting size matches the desired grid", () => { + const deps = makeDeps(); const executor = makeExecutor(deps); - // Desired: 3 contracts @95 (buy), 3 @96 (sell). Each contract rests as a - // separate qty=1 order, so seed 3 + 3 individual orders. - for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 1, "buy", 95_000_000n, 1n); - for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 10, "sell", 96_000_000n, 1n); + seedOrder(deps.book, 1, "buy", 95_000_000n, 3n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 3n); - executor.recordRequote(0, 0); // anchor mid → drift 0 + executor.recordRequote(0, 0); const planned = executor.plan([desiredBuy(95_000_000n, 3n), desiredSell(96_000_000n, 3n)]); - assert.equal(planned, null, "no churn: 6 resting orders == 6 desired contracts"); + assert.equal(planned, null, "no churn when size and prices match"); }); - /** - * When individual resting orders fall below the qty-expanded desired total - * (a partial fill on an exact venue), the deficit fast-path fires and the - * missing contracts are topped up — the pre-fix level-count comparison - * (2 desired levels vs 5 resting orders) would have missed this. - */ - it("requotes when resting contracts fall below the desired qty total", () => { + it("requotes when resting size falls below desired qty at a level", () => { const deps = makeDeps(); const executor = makeExecutor(deps); - // Only 2 of the 3 desired buy contracts remain (one filled); asks intact. - for (let i = 0; i < 2; i++) seedOrder(deps.book, i + 1, "buy", 95_000_000n, 1n); - for (let i = 0; i < 3; i++) seedOrder(deps.book, i + 10, "sell", 96_000_000n, 1n); + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 3n); executor.recordRequote(0, 0); const planned = executor.plan([desiredBuy(95_000_000n, 3n), desiredSell(96_000_000n, 3n)]); - assert.ok(planned, "requote triggered by qty-expanded count deficit"); - assert.equal(planned.creates.length, 1, "tops up the single missing buy contract"); + assert.ok(planned, "requote triggered by quantity deficit"); + assert.equal(planned.creates.length, 1, "tops up the missing buy size"); assert.equal(planned.creates[0].size, 1n); }); }); @@ -345,18 +334,11 @@ describe("OrderExecutor.plan", () => { }); }); -// ── limit-mode (perps) stale detection ───────────────────────────────────── - -describe("OrderExecutor limit-mode stale detection", () => { - function limitDeps(): TestDeps { - const deps = makeDeps(); - (deps.instrument as unknown as { book: { matchingMode: MatchingMode } }).book.matchingMode = - "limit"; - return deps; - } +// ── stale detection ──────────────────────────────────────────────────────── +describe("OrderExecutor stale detection", () => { it("keeps orders at-least-as-aggressive as the grid, cancels worse ones", () => { - const deps = limitDeps(); + const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 96_000_000n); // better than worst bid → keep seedOrder(deps.book, 2, "buy", 93_000_000n); // worse than worst bid → stale @@ -376,12 +358,12 @@ describe("OrderExecutor limit-mode stale detection", () => { }); it("treats every resting order on a side as stale when that side is absent from the grid", () => { - const deps = limitDeps(); + const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 96_000_000n); seedOrder(deps.book, 2, "buy", 93_000_000n); - seedOrder(deps.book, 3, "sell", 95_000_000n); // 95 > 96? no → keep - seedOrder(deps.book, 4, "sell", 98_000_000n); // 98 > 96? yes → stale + seedOrder(deps.book, 3, "sell", 95_000_000n); // better than ask@96 → keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // worse → stale // Desired has only an ask side → no desired bid → all resting buys stale. const planned = executor.plan([desiredSell(96_000_000n)]); @@ -404,7 +386,7 @@ describe("OrderExecutor reconcile gate and cancelAll", () => { canPlaceOrders: async () => false, } as unknown as RiskManager, }); - seedOrder(deps.book, 1, "buy", 99_000_000n); // stale vs desired buy@95 + seedOrder(deps.book, 1, "buy", 90_000_000n); // worse than desired buy@95 → stale const executor = makeExecutor(deps); await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); From 72863872247acf952c65a1ff4ab7f09629ed8781 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 21 Jul 2026 16:45:27 +0200 Subject: [PATCH 100/180] feat(mm,keeper): submit via updateOrders; align liquidation comments Route portfolio MM cancels/creates through a single cross-expiry `updateOrders` (deprecate venue multicall for writes). Point keeper venues at keeper-chosen liquidateOrders and document OverLiquidation as the oversize-partial guard instead of an on-chain IM clamp. Co-authored-by: Cursor --- keeper/src/coordinator/planner.ts | 2 +- keeper/src/predict/solve.ts | 15 +- keeper/src/venues/futures.ts | 50 +++++- keeper/src/venues/perps.ts | 148 ++++------------- keeper/src/venues/types.ts | 17 +- keeper/tests/integration/helpers.ts | 12 +- .../integration/keeper.integration.test.ts | 7 +- keeper/tests/integration/scenarios.ts | 2 +- market-maker/configs/futures.dev.yml | 2 +- market-maker/configs/futures.local.yml | 2 +- market-maker/configs/futures.prd.yml | 2 +- market-maker/configs/futures.stg.yml | 2 +- market-maker/configs/perps.dev.yml | 2 +- market-maker/configs/perps.local.yml | 2 +- market-maker/configs/perps.prd.yml | 2 +- market-maker/configs/perps.stg.yml | 2 +- market-maker/configs/portfolio.dev.yml | 4 +- market-maker/configs/portfolio.local.yml | 4 +- market-maker/configs/portfolio.prd.yml | 4 +- market-maker/configs/portfolio.stg.yml | 4 +- market-maker/schemas/futures.json | 4 +- market-maker/schemas/perps.json | 4 +- market-maker/schemas/portfolio.json | 6 +- .../src/adapters/futures/instrument.ts | 152 ++++++++++-------- market-maker/src/adapters/futures/venue.ts | 26 ++- market-maker/src/adapters/perps/instrument.ts | 137 ++++++++-------- market-maker/src/adapters/perps/venue.ts | 26 ++- market-maker/src/apps/futures/config.ts | 6 +- market-maker/src/apps/perps/config.ts | 6 +- market-maker/src/apps/portfolio/config.ts | 8 +- market-maker/src/core/adapter.ts | 32 +++- market-maker/src/core/orderExecutor.ts | 74 +++++---- market-maker/src/core/txCoordinator.ts | 142 +++++++++------- .../tests/apps/portfolio/config.test.ts | 2 +- market-maker/tests/core/oracleTracker.test.ts | 1 + market-maker/tests/core/orderExecutor.test.ts | 66 +++++--- market-maker/tests/core/txCoordinator.test.ts | 107 ++++++------ 37 files changed, 593 insertions(+), 491 deletions(-) diff --git a/keeper/src/coordinator/planner.ts b/keeper/src/coordinator/planner.ts index cf3febf..2ab64c3 100644 --- a/keeper/src/coordinator/planner.ts +++ b/keeper/src/coordinator/planner.ts @@ -241,7 +241,7 @@ export class Planner { } /** - * Fans out `liquidateOrders(user)` across every venue. Each venue handles + * Fans out `liquidateOrders(user, ids)` across every venue. Each venue handles * the "no orders" case internally and returns `{ skipped: "notLiquidatable" }` * — we collapse that to a zero-fee no-op. */ diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index 8069f82..cbeb0b6 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -182,14 +182,13 @@ function findClosestCrossings( // Close-to-IM-buffer sizing (the batched-liquidation solvers) // // The on-chain `liquidatePositions` (futures) / `liquidatePosition(user, -// closeQty)` (perps) do NOT recompute margin per unit — they close the -// keeper-supplied amount and enforce a single end-of-tx `OverLiquidation` -// guard: with positions remaining and a real IM buffer (`im > mm`), the -// leftover balance must sit at/under IM. These solvers pick, off-chain, the -// deepest close that keeps the account inside the `[MM, IM]` band (healthy but -// not over-liquidated). If no in-band partial exists (deep crash / bad debt) -// they fall back to a full close, which the contract lets through (the guard -// is skipped once no positions remain). +// closeQty)` (perps) treat the keeper-supplied amount as an upper bound and +// revert `OverLiquidation` when a partial leaves balance above IM with a real +// IM buffer (`im > mm`). These solvers pick, off-chain, the deepest close that +// keeps the account inside the `[MM, IM]` band (healthy but not +// over-liquidated). If no in-band partial exists (deep crash / bad debt) they +// fall back to a full close, which the contract lets through (the guard is +// skipped once no positions remain). // ─────────────────────────────────────────────────────────────────────────── /** diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 2bb5d0b..7715f2e 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -1,4 +1,4 @@ -import { pad, toHex, type Address, type Hex } from "viem"; +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"; @@ -17,6 +17,35 @@ import type { VenuePosition, } from "./types.ts"; +/** Local fragment until published futures ABI includes `liquidateOrders(user, ids[])`. */ +const LIQUIDATE_ORDERS_ABI = [ + { + type: "function", + name: "liquidateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_user", type: "address" }, + { name: "_orderIds", type: "bytes32[]" }, + ], + outputs: [], + }, +] as const; + +const FUTURES_LIQUIDATE_ORDERS_ABI = [ + ...FuturesAbi.filter( + (item) => + !( + typeof item === "object" && + item !== null && + "type" in item && + item.type === "function" && + "name" in item && + item.name === "liquidateOrders" + ), + ), + ...LIQUIDATE_ORDERS_ABI, +] as Abi; + /** * `Venue` adapter for the Futures contract (3.0 aggregate positions). * @@ -128,16 +157,29 @@ export class FuturesVenue implements Venue { async liquidateOrders( user: Address, - _ids?: readonly Hex[], + ids?: readonly Hex[], ): 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[]; + } + if (targetIds.length === 0) { + return { skipped: "notLiquidatable" }; + } + const result = await sendLiquidate({ chain: this.chain, config: this.config, logger: this.logger, address: this.config.futures.address, - abi: FuturesAbi, + abi: FUTURES_LIQUIDATE_ORDERS_ABI, functionName: "liquidateOrders", - args: [user], + args: [user, targetIds], feeEventName: "OrderLiquidated", ethUsdFeed: this.ethUsdFeed, }); diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index d98d825..1bcb717 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -1,19 +1,9 @@ -import { - encodeFunctionData, - keccak256, - pad, - parseEventLogs, - toHex, - type Address, - type Hex, - type TransactionReceipt, -} from "viem"; +import { keccak256, 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 { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; -import { formatGasCost } from "../tx/gasCost.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; import { solvePerpCloseToTarget } from "../predict/solve.ts"; import type { MMParams } from "../predict/types.ts"; @@ -27,6 +17,25 @@ import type { VenuePosition, } from "./types.ts"; +/** Local fragment until published perps ABI includes `liquidateOrders(user, ids[])`. */ +const LIQUIDATE_ORDERS_ABI = [ + { + type: "function", + name: "liquidateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_user", type: "address" }, + { name: "_orderIds", type: "bytes32[]" }, + ], + outputs: [], + }, +] as const; + +const PERPS_LIQUIDATE_ORDERS_ABI = [ + ...HashPowerPerpsDEXAbi, + ...LIQUIDATE_ORDERS_ABI, +] as Abi; + /** * `Venue` adapter for HashPowerPerpsDEX. Stateless beyond the wiring it * receives — no per-instance caches; perps has a single market and the @@ -125,25 +134,8 @@ export class PerpsVenue implements Venue { } /** - * Cancels every supplied resting order via a single - * `multicallStopOnFailure([liquidateOrder(user, id), ...])` transaction. - * - * The perps contract retired the dedicated batch entry point - * `liquidateOrders(user, ids[])`; the canonical replacement is N - * `liquidateOrder` sub-calls composed through - * {MulticallStopOnFailureUpgradeable}. The multicall: - * - * - Stops at the first sub-call that reverts (e.g. `NotLiquidatable` - * once cancelling earlier orders restored MM mid-batch). Earlier - * sub-calls keep their state changes and emit their `OrderLiquidated` - * events — we still pocket those fees. - * - Does *not* revert the whole tx for clean sub-call reverts, so the - * "user is healthy, do nothing" case requires inspecting the - * simulation's `successes` array rather than relying on a top-level - * throw. - * - Reverts the whole batch with `MulticallSubCallOutOfGas` on an - * empty-revert sub-call (typically OOG) — we let that bubble up so - * the executor re-queues. + * Cancels keeper-chosen resting orders via `liquidateOrders(user, ids[])`. + * On-chain stop-on-failure keeps prior cancels and stops when healthy. */ async liquidateOrders( user: Address, @@ -161,67 +153,24 @@ export class PerpsVenue implements Venue { } if (targetIds.length === 0) { - // Nothing to cancel — surface as `notLiquidatable` so the planner - // can bail on this leg without rolling back the wider plan. return { skipped: "notLiquidatable" }; } - const calls = targetIds.map((orderId) => - encodeFunctionData({ - abi: HashPowerPerpsDEXAbi, - functionName: "liquidateOrder", - args: [user, orderId], - }), - ); - - // Simulate first — `multicallStopOnFailure` never propagates a - // sub-call revert as a top-level revert, so the only way to detect - // "user is healthy, every sub-call would clean-revert" is to read - // `successes[0]` from the simulated return. - const sim = await this.chain.publicClient.simulateContract({ + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, address: this.config.perps.address, - abi: HashPowerPerpsDEXAbi, - functionName: "multicallStopOnFailure", - args: [calls], - account: this.chain.account, + abi: PERPS_LIQUIDATE_ORDERS_ABI, + functionName: "liquidateOrders", + args: [user, targetIds], + feeEventName: "OrderLiquidated", + ethUsdFeed: this.ethUsdFeed, }); - const successes = ( - sim.result as readonly [readonly boolean[], readonly Hex[]] - )[0]; - if (successes[0] === false) { - this.logger.debug( - { user, ordersTargeted: targetIds.length }, - "perps batch liquidate skipped — first sub-call would revert (user healthy)", - ); - return { skipped: "notLiquidatable" }; - } - - if (this.config.keeper.dryRun) { - this.logger.info( - { user, ordersTargeted: targetIds.length }, - "[dryRun] would send perps batch liquidate", - ); - return { feeEarned: 0n }; - } - const hash = await this.chain.walletClient.writeContract(sim.request); - const receipt = await this.chain.publicClient.waitForTransactionReceipt({ - hash, - confirmations: this.config.coordinator.confirmationBlocks, - }); - const feeEarned = sumOrderLiquidatedFees(receipt); - const ordersClosed = countSuccesses(successes); - this.logger.info( - { - user, - hash, - ordersClosed, - feeEarned, - ...formatGasCost(receipt, this.ethUsdFeed), - }, - "perps batch liquidate confirmed", - ); - return { feeEarned }; + return "skipped" in result + ? { skipped: "notLiquidatable" } + : { feeEarned: result.feeEarned }; } async reduceToTarget(user: Address): Promise { @@ -261,7 +210,7 @@ export class PerpsVenue implements Venue { feeEventName: "PositionLiquidated", mapSkip: (errorName) => { if (errorName === "OrdersStillOpen") return "ordersStillOpen"; - // `NotLiquidatable` / `OverLiquidation` (a price race) and any other + // `NotLiquidatable` (price race / already healthy) and any other // recoverable revert collapse to `notLiquidatable` — the planner's // recheck-then-retry loop re-snapshots and re-sizes. return "notLiquidatable"; @@ -301,30 +250,3 @@ function abs(x: bigint): bigint { function perpsPositionId(user: Address): Hex { return pad(user, { size: 32 }); } - -/** - * Walks a `multicallStopOnFailure` receipt and sums the `fee` field of every - * `OrderLiquidated` event. The multicall delegatecalls each sub-call into - * the contract's own storage, so every successful `liquidateOrder` emits - * one event on the receipt — they accumulate naturally. - */ -function sumOrderLiquidatedFees(receipt: TransactionReceipt): bigint { - const logs = parseEventLogs({ - abi: HashPowerPerpsDEXAbi, - logs: receipt.logs, - eventName: "OrderLiquidated", - }); - let total = 0n; - for (const log of logs) { - const fee = log.args.fee; - if (typeof fee === "bigint") total += fee; - } - return total; -} - -/** Counts the truthy entries in the multicall's `successes` array. */ -function countSuccesses(successes: readonly boolean[]): number { - let n = 0; - for (const s of successes) if (s) n++; - return n; -} diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index b21252d..3ef5039 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -43,8 +43,7 @@ export type LiquidateOrdersOutcome = * - `nothingToClose` — the off-chain sizing found the account already * at/above the IM buffer (no lots to close). * - `notLiquidatable` — the venue's on-chain predicate rejected the batch - * (healthy, or a stale snapshot / `OverLiquidation` race). Planner - * re-snapshots and retries. + * (healthy, or a stale snapshot / race). Planner re-snapshots and retries. * - `ordersStillOpen` — resting orders must be cleared first. */ export type ReduceToTargetOutcome = @@ -78,12 +77,9 @@ export interface Venue { readPositions(user: Address): Promise; /** - * Calls `liquidateOrders` on the venue. Cancels across all markets owned by - * `user` (or the supplied `ids` for venues that take a calldata id list). - * - * - Perps: takes `ids[]` so the keeper can multicall specific high-margin orders. - * - Futures: ignores `ids` — the contract sweeps FIFO until healthy. - * - Options: TBD when added. + * 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. */ liquidateOrders(user: Address, ids?: readonly Hex[]): Promise; @@ -98,10 +94,11 @@ export interface Venue { * a full close. * 3. Submit ONE tx — futures `liquidatePositions(user, expirationAts[], * closeQtys[])`, perps `liquidatePosition(user, closeQty)`. + * Oversize partials revert `OverLiquidation` (re-size off-chain). * * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first) or - * `OverLiquidation` (a price race made the sizing overshoot IM) are - * translated into `{ skipped }` so the planner re-plans without crashing. + * `NotLiquidatable` are translated into `{ skipped }` so the planner + * re-plans without crashing. */ reduceToTarget(user: Address): Promise; } diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 0ff4d7b..1d845c6 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -172,9 +172,10 @@ export interface AccountMargins { /** * Reads `(balanceOf, computePortfolioIM, computePortfolioMM)` for `user` — the - * on-chain source of truth the liquidation predicates (and the new - * `liquidatePositions` / partial-perps `OverLiquidation` guard) resolve back - * to. Used by `expectReducedToImBuffer` to assert the account landed inside the + * on-chain source of truth the liquidation predicates (and the end-of-tx + * `OverLiquidation` guard on `liquidatePositions` / perps `liquidatePosition`) + * resolve back to. + * Used by `expectReducedToImBuffer` to assert the account landed inside the * `[MM, IM]` band after a batched liquidation. Uses three parallel * `readContract` calls (the test's public client has no multicall3 configured, * matching every other reader in this file). @@ -212,9 +213,8 @@ export async function readAccountMargins( * * - `balance >= computePortfolioMM(user)` → healthy (not re-liquidatable) * - `balance <= computePortfolioIM(user)` → NOT over-liquidated (the - * contract's `OverLiquidation` guard tolerates landing at/under IM while - * positions remain; closing so much that balance exceeds IM would have - * reverted on-chain) + * contract reverts `OverLiquidation` when a partial would leave balance + * above IM) * * Polls until the batched liquidation tx has confirmed (balance drops into or * below the IM band) and then makes the hard band assertions with BigInt-safe diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index d42b98f..e991d55 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -228,9 +228,8 @@ describe("Perps liquidation", () => { async () => { // Precondition: alice holds a perps long AND a stale far-out-of- // market resting buy order. The perps venue cancels the resting - // order via `multicallStopOnFailure([liquidateOrder(user, id)])` - // (the contract retired the batch `liquidateOrders` entry point); - // the planner then walks the position-leg in the same plan. + // order via `liquidateOrders(user, ids)`; the planner then walks + // the position-leg in the same plan. const ctx = await loadFixture(perpsOrdersAndPositionFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); @@ -329,7 +328,7 @@ describe("Futures liquidation", () => { async () => { // Precondition: alice holds a long futures position AND a stale // far-out-of-market resting buy order. After the crash the planner - // must run orders-leg (FIFO sweep via `liquidateOrders(user)`) and + // must run orders-leg (`liquidateOrders(user, ids)`) and // position-leg in the same plan; we verify on-chain that both // legs end up empty. const ctx = await loadFixture(futuresOrdersAndPositionFixture, testClient); diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 418ddab..a7acba7 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -443,7 +443,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { * matches (Bob doesn't offer a sell at $2/day), so it sits on the book * until the planner walks the orders-leg. After the crash, the planner * must run: - * 1. `liquidateOrders(user)` on futures (FIFO sweep) → cancels the + * 1. `liquidateOrders(user, ids)` on futures → cancels the * resting order; * 2. `liquidatePosition(user, id)` → cash-settles the position. */ diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 2d93e7f..7b09516 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -90,4 +90,4 @@ health: readBatchSize: 30 # Per-operation batch sizes for writes (futures: closeOrder limit / createOrders limit). -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 012c4a4..56986e3 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -77,4 +77,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index f1c6019..fc64a22 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -83,4 +83,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 24abac4..bfad7a9 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -83,4 +83,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 4701e76..efc37d5 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -93,4 +93,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes (perps: individual cancelOrder / createOrder). -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index e2bf88a..9ddbcb9 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -83,4 +83,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index d34611d..6db70d0 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -85,4 +85,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index 7b17b9e..0774559 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -84,4 +84,4 @@ health: readBatchSize: 100 # Per-operation batch sizes for writes. -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index 28cbd5d..e204a31 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -124,7 +124,7 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. Lowered from 50 for base-sepolia. - maxCallsPerTx: 20 + maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 @@ -139,4 +139,4 @@ rollCheckIntervalSec: 300 sharedStalenessGraceSec: 30 readBatchSize: 100 -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index 6a2e3d1..4428b8f 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -99,7 +99,7 @@ health: # Centralized submission / nonce recovery. txCoordinator: - maxCallsPerTx: 50 + maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 @@ -114,4 +114,4 @@ rollCheckIntervalSec: 300 sharedStalenessGraceSec: 30 readBatchSize: 100 -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index 6b2373f..63a22c3 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -116,7 +116,7 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. - maxCallsPerTx: 20 + maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 @@ -131,4 +131,4 @@ rollCheckIntervalSec: 300 sharedStalenessGraceSec: 30 readBatchSize: 100 -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml index d340040..8973eeb 100644 --- a/market-maker/configs/portfolio.stg.yml +++ b/market-maker/configs/portfolio.stg.yml @@ -116,7 +116,7 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. - maxCallsPerTx: 20 + maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 @@ -131,4 +131,4 @@ rollCheckIntervalSec: 300 sharedStalenessGraceSec: 30 readBatchSize: 100 -writeBatchSize: 20 +writeBatchSize: 100 diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 2488d84..9794247 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -935,8 +935,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Maximum qty per write batch. The adapter groups cancels into chunks of this size before sending.", - "default": 20 + "description": "Max cost units per write tx (cancel=1, createOrders=Σ qty). Cancels and creates may split across txs when the budget fills.", + "default": 100 } } } diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index a074c22..134b5f3 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -912,8 +912,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Maximum qty per write batch. The adapter groups cancels into chunks of this size before sending.", - "default": 20 + "description": "Max cost units per write tx (cancel=1, createOrders=N). Cancels and creates may split across txs when the budget fills.", + "default": 100 } } } diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 1108a54..38c29fc 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -1228,8 +1228,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Max encoded multicall entries per tx before chunking.", - "default": 50 + "description": "Max cost units per tx before chunking (not raw call count).", + "default": 100 }, "confirmationTimeoutSec": { "default": 60, @@ -1492,7 +1492,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "default": 20 + "default": 100 } } } diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 11c2528..dcd6909 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -95,6 +95,52 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { }); } + encodeUpdateOrders( + cancels: CancelIntent[], + creates: OrderIntent[], + ): `0x${string}` { + if (cancels.length === 0 && creates.length === 0) { + throw new Error("futures: encodeUpdateOrders requires cancels and/or creates"); + } + // Local ABI fragment until published futures-contracts includes updateOrders. + const updateOrdersAbi = [ + { + type: "function", + name: "updateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_intents", + type: "tuple[]", + components: [ + { name: "price", type: "uint256" }, + { name: "expirationAt", type: "uint256" }, + { name: "quantity", type: "int256" }, + ], + }, + ], + outputs: [], + }, + ] as const; + const batch = creates.map((intent) => { + const qty = intent.size; + if (qty <= 0n) { + throw new Error(`futures: order size ${qty} must be > 0`); + } + return { + price: intent.price, + expirationAt: intent.expirationAt ?? this.expirationAt, + quantity: intent.side === "buy" ? qty : -qty, + }; + }); + return encodeFunctionData({ + abi: updateOrdersAbi, + functionName: "updateOrders", + args: [cancels.map((c) => c.orderId), batch], + }); + } + encodeCancel(intent: CancelIntent): `0x${string}` { return encodeFunctionData({ abi: FuturesAbi, @@ -112,21 +158,18 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** - * Execute cancels then creates for this expiry. Kept for single-market - * callers and tests; the portfolio runner routes through the shared - * `TxCoordinator` instead, which batches this expiry's calls with the other - * expiries' into one `Futures.multicall`. + * Execute cancels then creates for this expiry via `updateOrders`. Kept for + * single-market callers and tests; the portfolio runner routes through the + * shared `TxCoordinator` instead. */ async executeOrders(intent: ExecuteOrdersIntent): Promise { return this.executeOrdersImpl(intent, this.venue.getLogger()); } - /** Build the ordered call list for this expiry: cancels then creates. */ + /** Build the call list for this expiry: one `updateOrders` when there is work. */ buildCalls(intent: { cancels: CancelIntent[]; creates: OrderIntent[] }): `0x${string}`[] { - const calls: `0x${string}`[] = []; - for (const c of intent.cancels) calls.push(this.encodeCancel(c)); - for (const c of intent.creates) calls.push(this.encodeCreate(c)); - return calls; + if (intent.cancels.length === 0 && intent.creates.length === 0) return []; + return [this.encodeUpdateOrders(intent.cancels, intent.creates)]; } // ── Private implementation ────────────────────────────────────────── @@ -135,76 +178,45 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - const batches = this.chunkCalls(intent); + if (intent.cancels.length === 0 && intent.creates.length === 0) { + return { receipts: [], errors: [] }; + } + const data = this.encodeUpdateOrders(intent.cancels, intent.creates); if (intent.dryRun) { logger.info( { cancels: intent.cancels.length, creates: intent.creates.length }, - "DRY RUN: would send multicall batches", + "DRY RUN: would send updateOrders", ); return { receipts: [], errors: [] }; } - const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; - const errors: Error[] = []; - for (let batchNum = 0; batchNum < batches.length; batchNum++) { - const chunk = batches[batchNum]; - try { - const hash = await this.venue.multicall(chunk, { - maxFeePerGas: intent.maxFeePerGas, - }); - const receipt = await this.venue.publicClient.waitForTransactionReceipt({ - hash, - }); - receipts.push({ - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, - }); - logger.info( - { - calls: chunk.length, - batch: `${batchNum}/${batches.length}`, - gas: receipt.gasUsed.toString(), - }, - "futures multicall chunk executed", - ); - } catch (err) { - const wrapped = err instanceof Error ? err : new Error(String(err)); - errors.push(wrapped); - logger.error( - { err: wrapped, calls: chunk.length, batch: `${batchNum}/${batches.length}` }, - "futures multicall chunk failed — continuing with next chunk", - ); - } + try { + const hash = await this.venue.sendCall(data, { + maxFeePerGas: intent.maxFeePerGas, + }); + const receipt = await this.venue.publicClient.waitForTransactionReceipt({ + hash, + }); + logger.info( + { + cancels: intent.cancels.length, + creates: intent.creates.length, + gas: receipt.gasUsed.toString(), + }, + "futures updateOrders executed", + ); + return { + receipts: [ + { gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice }, + ], + errors: [], + }; + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + logger.error({ err: wrapped }, "futures updateOrders failed"); + return { receipts: [], errors: [wrapped] }; } - return { receipts, errors }; - } - - /** - * Split cancels+creates into tx-sized chunks. Batch size is measured in qty - * count since one cancel costs roughly one qty=1 create. - */ - private chunkCalls(intent: { - cancels: CancelIntent[]; - creates: OrderIntent[]; - }): `0x${string}`[][] { - const batchSize = this.venue.writeBatchSize; - const batches: `0x${string}`[][] = []; - let current: `0x${string}`[] = []; - let qtyCount = 0; - const push = (tx: `0x${string}`, qty: number) => { - current.push(tx); - qtyCount += qty; - if (current.length >= batchSize || qtyCount >= batchSize) { - batches.push(current); - current = []; - qtyCount = 0; - } - }; - for (const c of intent.cancels) push(this.encodeCancel(c), 1); - for (const c of intent.creates) push(this.encodeCreate(c), Number(c.size)); - if (current.length > 0) batches.push(current); - return batches; } /** diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 5850653..814631d 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -242,6 +242,30 @@ export class FuturesVenueAdapter implements VenueAdapter { return out; } + async sendCall( + data: `0x${string}`, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, + ): Promise<`0x${string}`> { + try { + return await this.wallet.walletClient.sendTransaction({ + to: this.address, + data, + 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, + }); + } + } + + /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ async multicall( calls: `0x${string}`[], opts: { maxFeePerGas?: bigint; nonce?: number } = {}, @@ -258,8 +282,6 @@ export class FuturesVenueAdapter implements VenueAdapter { nonce: opts.nonce, }); } catch (err) { - // Attach a Tenderly simulation URL so the failed multicall can be - // replayed/debugged with one click from the log. throw attachTenderlyUrl(err, { chainId: this.chain.id, from: this.wallet.account.address, diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 7f2d98c..ae17dbe 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -75,6 +75,44 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { }); } + encodeUpdateOrders( + cancels: CancelIntent[], + creates: OrderIntent[], + ): `0x${string}` { + if (cancels.length === 0 && creates.length === 0) { + throw new Error("perps: encodeUpdateOrders requires cancels and/or creates"); + } + // Local ABI fragment until published perps-contracts includes updateOrders. + const updateOrdersAbi = [ + { + type: "function", + name: "updateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_intents", + type: "tuple[]", + components: [ + { name: "price", type: "uint256" }, + { name: "quantity", type: "int256" }, + ], + }, + ], + outputs: [], + }, + ] as const; + const batch = creates.map((intent) => ({ + price: intent.price, + quantity: intent.side === "buy" ? intent.size : -intent.size, + })); + return encodeFunctionData({ + abi: updateOrdersAbi, + functionName: "updateOrders", + args: [cancels.map((c) => c.orderId), batch], + }); + } + encodeCancel(intent: CancelIntent): `0x${string}` { return encodeFunctionData({ abi: HashPowerPerpsDEXAbi, @@ -92,8 +130,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } /** - * Execute cancels then creates on-chain. Perps uses individual - * cancelOrder / createOrder calls (no batch functions on the contract). + * Execute cancels + creates via `updateOrders` (IM checked once on-chain). */ async executeOrders( intent: ExecuteOrdersIntent, @@ -111,89 +148,49 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - // 1. Build the ordered call list: cancels first, then creates. - const cancelSize = this.venue.cancelBatchSize; - const createSize = this.venue.createBatchSize; - const calls: `0x${string}`[] = []; - - // Cancels: chunk by cancelBatchSize. - for (let i = 0; i < intent.cancels.length; i += cancelSize) { - const batch = intent.cancels.slice(i, i + cancelSize); - for (const c of batch) calls.push(this.encodeCancel(c)); - } - - // Creates: chunk by createBatchSize. - for (let i = 0; i < intent.creates.length; i += createSize) { - const batch = intent.creates.slice(i, i + createSize); - for (const c of batch) calls.push(this.encodeCreate(c)); - } - - if (calls.length === 0) { + if (intent.cancels.length === 0 && intent.creates.length === 0) { return { receipts: [], errors: [] }; } - // 2. Chunk into tx-sized groups and broadcast sequentially. - // Perps has no batch contract functions — each cancel/create is one call. - // The cancelBatchSize / createBatchSize already control grouping, so we - // use a generous tx-level safety limit. - const max = 200; + const data = this.encodeUpdateOrders(intent.cancels, intent.creates); if (intent.dryRun) { - const totalBatches = Math.ceil(calls.length / max); logger.info( { cancels: intent.cancels.length, creates: intent.creates.length, - calls: calls.length, - batches: totalBatches, }, - "DRY RUN: would send multicall batches", + "DRY RUN: would send updateOrders", ); return { receipts: [], errors: [] }; } - const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; - const errors: Error[] = []; - const totalBatches = Math.ceil(calls.length / max); - - for (let offset = 0; offset < calls.length; offset += max) { - const chunk = calls.slice(offset, offset + max); - const batchNum = Math.floor(offset / max) + 1; - - try { - const hash = await this.venue.multicall(chunk, { - maxFeePerGas: intent.maxFeePerGas, - }); - const receipt = await this.venue.publicClient.waitForTransactionReceipt( - { hash }, - ); - receipts.push({ - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, - }); - logger.info( - { - calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, - gas: receipt.gasUsed.toString(), - }, - "perps multicall chunk executed", - ); - } catch (err) { - const wrapped = err instanceof Error ? err : new Error(String(err)); - errors.push(wrapped); - logger.error( - { - err: wrapped, - calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, - }, - "perps multicall chunk failed — continuing with next chunk", - ); - } + try { + const hash = await this.venue.sendCall(data, { + maxFeePerGas: intent.maxFeePerGas, + }); + const receipt = await this.venue.publicClient.waitForTransactionReceipt({ + hash, + }); + logger.info( + { + cancels: intent.cancels.length, + creates: intent.creates.length, + gas: receipt.gasUsed.toString(), + }, + "perps updateOrders executed", + ); + return { + receipts: [ + { gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice }, + ], + errors: [], + }; + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + logger.error({ err: wrapped }, "perps updateOrders failed"); + return { receipts: [], errors: [wrapped] }; } - - return { receipts, errors }; } /** diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index abc9f14..ec0e4bf 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -158,6 +158,30 @@ export class PerpsVenueAdapter implements VenueAdapter { return [await this.getInstrument()]; } + async sendCall( + data: `0x${string}`, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, + ): Promise<`0x${string}`> { + try { + return await this.wallet.walletClient.sendTransaction({ + to: this.address, + data, + 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, + }); + } + } + + /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ async multicall( calls: `0x${string}`[], opts: { maxFeePerGas?: bigint; nonce?: number } = {}, @@ -174,8 +198,6 @@ export class PerpsVenueAdapter implements VenueAdapter { nonce: opts.nonce, }); } catch (err) { - // Attach a Tenderly simulation URL so the failed multicall can be - // replayed/debugged with one click from the log. throw attachTenderlyUrl(err, { chainId: this.chain.id, from: this.wallet.account.address, diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index 83ae998..ba5485a 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -169,10 +169,10 @@ export const futuresRootSchema = Type.Object( }), writeBatchSize: Type.Number({ minimum: 1, - default: 20, + default: 100, description: - "Maximum qty per write batch. " + - "The adapter groups cancels into chunks of this size before sending.", + "Max cost units per write tx (cancel=1, createOrders=Σ qty). " + + "Cancels and creates may split across txs when the budget fills.", }), }, { ...Closed, description: "Titan Market Maker — Futures app config." }, diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 0d0ec83..cf88fe2 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -157,10 +157,10 @@ export const perpsRootSchema = Type.Object( }), writeBatchSize: Type.Number({ minimum: 1, - default: 20, + default: 100, description: - "Maximum qty per write batch. " + - "The adapter groups cancels into chunks of this size before sending.", + "Max cost units per write tx (cancel=1, createOrders=N). " + + "Cancels and creates may split across txs when the budget fills.", }), }, { ...Closed, description: "Titan Market Maker — Perps app config." }, diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts index ce6d737..b6d2c00 100644 --- a/market-maker/src/apps/portfolio/config.ts +++ b/market-maker/src/apps/portfolio/config.ts @@ -106,8 +106,8 @@ const txCoordinatorSchema = Type.Object( { maxCallsPerTx: Type.Integer({ minimum: 1, - default: 50, - description: "Max encoded multicall entries per tx before chunking.", + default: 100, + description: "Max cost units per tx before chunking (not raw call count).", }), confirmationTimeoutSec: TypeSeconds({ minimum: 1, @@ -194,7 +194,7 @@ export const portfolioRootSchema = Type.Object( "Seconds shared inputs may be stale before new placements are paused (existing orders kept).", }), readBatchSize: Type.Number({ minimum: 1, default: 10 }), - writeBatchSize: Type.Number({ minimum: 1, default: 20 }), + writeBatchSize: Type.Number({ minimum: 1, default: 100 }), }, { ...Closed, description: "Titan Market Maker — unified portfolio app config." }, ); @@ -323,7 +323,7 @@ export function loadPortfolioConfig( env: opts.env, parse: (raw) => { const tx = raw.txCoordinator ?? { - maxCallsPerTx: 50, + maxCallsPerTx: 100, confirmationTimeoutSec: 60, maxReplacements: 2, replacementFeeBumpPct: 15, diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 180c12f..2de7138 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -25,6 +25,12 @@ export interface OrderIntent { side: Side; price: bigint; size: bigint; + /** + * Futures delivery date (unix seconds). Required when encoding a cross-expiry + * `updateOrders` batch; per-instrument encoders fall back to their market's + * expiry when omitted. Ignored on perps. + */ + expirationAt?: bigint; } export interface CancelIntent { @@ -297,6 +303,16 @@ export interface InstrumentAdapter { encodeCreate(intent: OrderIntent): `0x${string}`; encodeCancel(intent: CancelIntent): `0x${string}`; + /** + * Encode venue `updateOrders(cancelIds, creates)` — cancels first, then + * GTC creates, with a single end-of-call collateral check. Either side may + * be empty; callers must skip the encode when both are empty. + */ + encodeUpdateOrders( + cancels: CancelIntent[], + creates: OrderIntent[], + ): `0x${string}`; + /** * Relative gas weight of placing this create, in "cost units" where one unit * is roughly the cheapest single call. The shared `TxCoordinator` sums these @@ -306,6 +322,7 @@ export interface InstrumentAdapter { * - Futures: `size` (qty) — `createOrder(…, int8 qty)` does one unit of * work per contract, so gas scales with total qty, not calls. * Cancels are always weight 1 (the coordinator assumes this). + * A batched `encodeUpdateOrders` call weighs cancels + Σ create weights. */ createCallWeight(intent: OrderIntent): number; @@ -381,9 +398,18 @@ export interface VenueAdapter { listInstruments(): Promise; /** - * Batch cancels/creates in one tx. Returns tx hash. Implementations route - * through the venue contract's multicall function. `nonce` is supplied by the - * shared NonceManager when the portfolio runner sequences multi-venue txs. + * Send a single calldata payload to the venue contract (e.g. `updateOrders`). + * `nonce` is supplied by the shared NonceManager when the portfolio runner + * sequences multi-venue txs. + */ + sendCall( + 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}`[], diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 1be9971..a0481fe 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -25,9 +25,10 @@ export interface OrderExecutorConfig { /** * Diff desired quotes vs the resting book; cancel + place via venue multicall. * - * Stale-order detection (limit LOB): a resting buy is stale iff its price < - * worst desired bid; a resting sell is stale iff its price > worst desired ask. - * Orders better-than-the-grid are kept (better priority + better price). + * Exact set-diff (limit LOB): cancel resting orders whose (side, price) is not + * in the desired grid, or that contribute excess size at a desired price; + * create only size deficits at desired prices. The resting book is driven to + * match the quote grid — no band-based “keep better leftovers” policy. */ export class OrderExecutor { readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; @@ -95,7 +96,7 @@ export class OrderExecutor { } const cancels = this.findStaleOrders(desired); - const creates = this.findNewOrders(desired); + const creates = this.findNewOrders(desired, cancels); if (cancels.length === 0 && creates.length === 0) { this.logger.debug("no order changes needed"); return null; @@ -257,39 +258,54 @@ export class OrderExecutor { } /** - * Stale = should be cancelled. Keep orders at-least-as-aggressive as the - * worst desired price for that side (higher bid / lower ask). + * Cancel targets for an exact set-diff against `desired`: + * - every resting order at a (side, price) not in the desired grid + * - at desired prices, enough whole orders that aggregated size exceeds + * desired (cancel until remaining ≤ desired; deficits are topped up + * by `findNewOrders`) */ private findStaleOrders(desired: OrderIntent[]): OwnOrder[] { - let worstDesiredBid: bigint | undefined; - let worstDesiredAsk: bigint | undefined; + const desiredSize = new Map(); for (const i of desired) { - if (i.side === "buy") { - if (worstDesiredBid === undefined || i.price < worstDesiredBid) - worstDesiredBid = i.price; - } else { - if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) - worstDesiredAsk = i.price; - } + const k = keyOf(i.side, i.price); + desiredSize.set(k, (desiredSize.get(k) ?? 0n) + i.size); } - const stale: OwnOrder[] = []; + + const byKey = new Map(); for (const order of this.book.ownOrders.values()) { - if (order.side === "buy") { - if (worstDesiredBid === undefined || order.price < worstDesiredBid) { - stale.push(order); - } - } else { - if (worstDesiredAsk === undefined || order.price > worstDesiredAsk) { - stale.push(order); - } + const k = keyOf(order.side, order.price); + const list = byKey.get(k); + if (list) list.push(order); + else byKey.set(k, [order]); + } + + const stale: OwnOrder[] = []; + for (const [k, orders] of byKey) { + const want = desiredSize.get(k); + if (want === undefined) { + for (const o of orders) stale.push(o); + continue; + } + let have = 0n; + for (const o of orders) have += o.size; + if (have <= want) continue; + // Drop whole orders until remaining size fits; prefer cancelling the + // trailing entries so FIFO priority of earlier quotes is preserved. + let excess = have - want; + for (let i = orders.length - 1; i >= 0 && excess > 0n; i--) { + stale.push(orders[i]); + excess -= orders[i].size; } } return stale; } - /** New orders = desired levels missing size at exactly the desired price. */ - private findNewOrders(desired: OrderIntent[]): OrderIntent[] { - const existing = this.aggregateOwnSizeByPriceSide(); + /** + * New orders = desired levels missing size at exactly the desired price, + * after subtracting any orders already selected for cancel in this plan. + */ + private findNewOrders(desired: OrderIntent[], cancels: OwnOrder[]): OrderIntent[] { + const existing = this.aggregateOwnSizeByPriceSide(cancels); const out: OrderIntent[] = []; for (const i of desired) { const have = existing.get(keyOf(i.side, i.price)) ?? 0n; @@ -313,9 +329,11 @@ export class OrderExecutor { return false; } - private aggregateOwnSizeByPriceSide(): Map { + private aggregateOwnSizeByPriceSide(cancels: OwnOrder[] = []): Map { + const cancelled = new Set(cancels.map((c) => c.orderId)); const m = new Map(); for (const o of this.book.ownOrders.values()) { + if (cancelled.has(o.orderId)) continue; const k = keyOf(o.side, o.price); m.set(k, (m.get(k) ?? 0n) + o.size); } diff --git a/market-maker/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts index 22652bd..34151dd 100644 --- a/market-maker/src/core/txCoordinator.ts +++ b/market-maker/src/core/txCoordinator.ts @@ -16,12 +16,9 @@ export interface MarketIntents { export interface TxCoordinatorConfig { /** - * Max cost units per on-chain tx before chunking. One unit is the cheapest - * single call (see `InstrumentAdapter.createCallWeight`): for perps that's - * one order per price level; for futures it's one contract (qty=1), since a - * futures `createOrder` does one unit of work per qty and its gas scales with - * total qty rather than call count. A single call heavier than the budget is - * still sent alone (it can't be split). Default 50. + * Max cost units per on-chain tx before splitting a venue's work across + * sequential `updateOrders` txs. One unit is the cheapest single call (see + * `InstrumentAdapter.createCallWeight`). Cancels weigh 1 each. Default 50. */ maxCallsPerTx?: number; } @@ -52,11 +49,11 @@ export interface SubmitResult { * * Responsibilities: * 1. Aggregate pre-trade gate over ALL markets' creates (one canPlaceOrder). - * 2. Group intents by venue — expiries on the same Futures contract merge - * into one `Futures.multicall`; perps is its own contract (≥2 txs total). - * 3. Cancels-before-creates within each venue batch (free margin first). - * 4. Submit each venue independently via the shared NonceManager: a revert or - * timeout on one venue is recorded and never blocks the other. + * 2. Group intents by venue — all futures expiries merge into one + * `updateOrders(cancels, creates)` (cancels first, one IM check). + * 3. Submit each venue via `sendCall` (no multicall). Oversized work splits + * into sequential txs: cancel-only chunks first, then create-only chunks. + * 4. Isolate venue failures: a revert on one venue never blocks the other. */ export class TxCoordinator { private readonly nonce: NonceManager; @@ -108,55 +105,51 @@ export class TxCoordinator { // 3. Build + submit per venue, isolated. for (const [venue, markets] of byVenue) { - // Each call carries a cost weight so one shared per-tx budget can chunk - // venues with very different per-call gas (perps: 1/level; futures: qty). - const calls: WeightedCall[] = []; - let cancelCount = 0; - let placeCount = 0; + const encoder = markets[0]?.instrument; + if (!encoder) continue; - // Cancels first (all markets), then creates (all markets). + const cancels: CancelIntent[] = []; + const creates: OrderIntent[] = []; for (const m of markets) { - for (const c of m.cancels) { - calls.push({ data: m.instrument.encodeCancel(c), weight: 1 }); - cancelCount++; - } - } - if (allowCreates) { - for (const m of markets) { - for (const c of m.creates) { - calls.push({ - data: m.instrument.encodeCreate(c), - weight: Math.max(1, m.instrument.createCallWeight(c)), - }); - placeCount++; - } + cancels.push(...m.cancels); + if (!allowCreates) continue; + const expiry = instrumentExpirationAt(m.instrument); + for (const c of m.creates) { + creates.push(expiry !== undefined ? { ...c, expirationAt: c.expirationAt ?? expiry } : c); } } - if (calls.length === 0) continue; + if (cancels.length === 0 && creates.length === 0) continue; + + const payloads = this.encodeVenueUpdateOrders(encoder, cancels, creates); if (opts.dryRun) { this.logger.info( - { venue: venue.kind, cancels: cancelCount, creates: placeCount, calls: calls.length }, - "DRY RUN: would submit venue batch", + { + venue: venue.kind, + cancels: cancels.length, + creates: creates.length, + txs: payloads.length, + }, + "DRY RUN: would submit venue updateOrders", ); - result.ordersCancelled += cancelCount; - result.ordersPlaced += placeCount; + result.ordersCancelled += cancels.length; + result.ordersPlaced += creates.length; continue; } try { - const chunks = this.chunk(calls); - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; + for (let i = 0; i < payloads.length; i++) { + const data = payloads[i]; const outcome = await this.nonce.submit( - ({ nonce, maxFeePerGas }) => venue.multicall(chunk, { maxFeePerGas, nonce }), + ({ nonce, maxFeePerGas }) => + venue.sendCall(data, { maxFeePerGas, nonce }), { maxFeePerGas: opts.maxFeePerGas, label: `${venue.kind}#${i}` }, ); result.receipts.push(outcome); } - result.ordersCancelled += cancelCount; - result.ordersPlaced += placeCount; + result.ordersCancelled += cancels.length; + result.ordersPlaced += creates.length; } catch (err) { const wrapped = err instanceof Error ? err : new Error(String(err)); result.errors.push(wrapped); @@ -171,30 +164,57 @@ export class TxCoordinator { } /** - * Pack calls into chunks whose summed weight stays within `maxCallsPerTx`. - * A single call heavier than the budget occupies its own chunk (it can't be - * split), so the invariant is "at most one over-budget call per chunk". + * Encode one or more `updateOrders` payloads for a venue. Prefer a single + * call (all cancels then all creates, one IM check). When over budget, split + * into cancel-only chunks followed by create-only chunks so later creates + * still see earlier cancels' freed margin across sequential txs. */ - private chunk(calls: WeightedCall[]): `0x${string}`[][] { - const out: `0x${string}`[][] = []; - let current: `0x${string}`[] = []; - let weight = 0; - for (const c of calls) { - if (current.length > 0 && weight + c.weight > this.maxCallsPerTx) { - out.push(current); - current = []; - weight = 0; + private encodeVenueUpdateOrders( + encoder: InstrumentAdapter, + cancels: CancelIntent[], + creates: OrderIntent[], + ): `0x${string}`[] { + let weight = cancels.length; + for (const c of creates) weight += Math.max(1, encoder.createCallWeight(c)); + + if (weight <= this.maxCallsPerTx) { + return [encoder.encodeUpdateOrders(cancels, creates)]; + } + + const out: `0x${string}`[] = []; + for (const slice of chunkArray(cancels, this.maxCallsPerTx)) { + out.push(encoder.encodeUpdateOrders(slice, [])); + } + + let createBuf: OrderIntent[] = []; + let createWeight = 0; + for (const c of creates) { + const w = Math.max(1, encoder.createCallWeight(c)); + if (createBuf.length > 0 && createWeight + w > this.maxCallsPerTx) { + out.push(encoder.encodeUpdateOrders([], createBuf)); + createBuf = []; + createWeight = 0; } - current.push(c.data); - weight += c.weight; + createBuf.push(c); + createWeight += w; + } + if (createBuf.length > 0) { + out.push(encoder.encodeUpdateOrders([], createBuf)); } - if (current.length > 0) out.push(current); return out; } } -/** An encoded call tagged with its relative gas cost (see createCallWeight). */ -interface WeightedCall { - data: `0x${string}`; - weight: number; +function instrumentExpirationAt(instrument: InstrumentAdapter): bigint | undefined { + const expiry = (instrument as { expirationAt?: unknown }).expirationAt; + return typeof expiry === "bigint" ? expiry : undefined; +} + +function chunkArray(items: T[], size: number): T[][] { + if (items.length === 0) return []; + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; } diff --git a/market-maker/tests/apps/portfolio/config.test.ts b/market-maker/tests/apps/portfolio/config.test.ts index f0f58bc..cbf201a 100644 --- a/market-maker/tests/apps/portfolio/config.test.ts +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -103,7 +103,7 @@ describe("loadPortfolioConfig", () => { it("applies txCoordinator and circuitBreaker defaults", () => { const path = writeTmp(tmpDir, "test.yml", VALID_YAML); const cfg = loadPortfolioConfig({ path }); - assert.equal(cfg.txCoordinator.maxCallsPerTx, 50); + assert.equal(cfg.txCoordinator.maxCallsPerTx, 100); assert.equal(cfg.txCoordinator.confirmationTimeoutMs, 60_000); assert.equal(cfg.circuitBreaker.quarantineThreshold, 3); assert.equal(cfg.rollCheckIntervalMs, 300_000); diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index 4a52a48..787378a 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -33,6 +33,7 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { getPosition: async () => ({ netQuantity: 0n, entryPrice: 0n }), getContext: async () => ({}), encodeCreate: () => "0x", + encodeUpdateOrders: () => "0x", encodeCancel: () => "0x", executeOrders: async () => ({ receipts: [], errors: [] }), estimateOrderMargin: () => 0n, diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 5b50f0d..395108f 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -167,17 +167,17 @@ describe("OrderExecutor requote guards (regression)", () => { }); /** - * Worse leftovers coexist with correct grid orders — cancel only the worse - * ones. Better-than-grid leftovers are kept (limit LOB policy). + * Off-grid leftovers coexist with correct grid orders — cancel only the + * off-grid ones (exact set-diff). */ - it("cancels worse leftovers while keeping the desired grid", async () => { + it("cancels off-grid leftovers while keeping the desired grid", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - // Worse leftovers (cancels failed on a prior tick). + // Off-grid leftovers (cancels failed on a prior tick). seedOrder(deps.book, 3, "buy", 90_000_000n, 1_000_000n); seedOrder(deps.book, 4, "sell", 101_000_000n, 1_000_000n); @@ -188,7 +188,7 @@ describe("OrderExecutor requote guards (regression)", () => { await executor.reconcile(desired); - assert.equal(deps.cancelledOrderIds.length, 2, "worse leftovers cancelled"); + assert.equal(deps.cancelledOrderIds.length, 2, "off-grid leftovers cancelled"); assert.equal( deps.placedIntents.length, 0, @@ -196,18 +196,18 @@ describe("OrderExecutor requote guards (regression)", () => { ); }); - it("keeps better-than-grid leftovers (does not cancel them as stale)", async () => { + it("cancels better-than-grid leftovers not on the desired set", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid - seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask + seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid, off-grid + seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask, off-grid await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); - assert.equal(deps.cancelledOrderIds.length, 0, "better leftovers kept"); + assert.equal(deps.cancelledOrderIds.length, 2, "off-grid leftovers cancelled"); assert.equal(deps.placedIntents.length, 0); }); @@ -337,41 +337,57 @@ describe("OrderExecutor.plan", () => { // ── stale detection ──────────────────────────────────────────────────────── describe("OrderExecutor stale detection", () => { - it("keeps orders at-least-as-aggressive as the grid, cancels worse ones", () => { + it("cancels off-grid prices and keeps exact desired levels", () => { const deps = makeDeps(); const executor = makeExecutor(deps); - seedOrder(deps.book, 1, "buy", 96_000_000n); // better than worst bid → keep - seedOrder(deps.book, 2, "buy", 93_000_000n); // worse than worst bid → stale - seedOrder(deps.book, 3, "sell", 95_000_000n); // better than worst ask → keep - seedOrder(deps.book, 4, "sell", 98_000_000n); // worse than worst ask → stale + seedOrder(deps.book, 1, "buy", 95_000_000n); // on-grid → keep + seedOrder(deps.book, 2, "buy", 93_000_000n); // off-grid → cancel + seedOrder(deps.book, 3, "sell", 96_000_000n); // on-grid → keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // off-grid → cancel const planned = executor.plan([ desiredBuy(95_000_000n), - desiredBuy(94_000_000n), // worst desired bid + desiredBuy(94_000_000n), desiredSell(96_000_000n), - desiredSell(97_000_000n), // worst desired ask + desiredSell(97_000_000n), ]); assert.ok(planned); const cancelled = new Set(planned.cancels.map((o) => o.orderId)); - assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "worse cancelled"); - assert.ok(!cancelled.has(makeOrderId(1)) && !cancelled.has(makeOrderId(3)), "better kept"); + assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "off-grid cancelled"); + assert.ok(!cancelled.has(makeOrderId(1)) && !cancelled.has(makeOrderId(3)), "on-grid kept"); + assert.equal(planned.creates.length, 2, "missing grid levels placed"); }); - it("treats every resting order on a side as stale when that side is absent from the grid", () => { + it("cancels every resting order on a side when that side is absent from the grid", () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 96_000_000n); seedOrder(deps.book, 2, "buy", 93_000_000n); - seedOrder(deps.book, 3, "sell", 95_000_000n); // better than ask@96 → keep - seedOrder(deps.book, 4, "sell", 98_000_000n); // worse → stale + seedOrder(deps.book, 3, "sell", 96_000_000n); // on desired ask → keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // off-grid → cancel - // Desired has only an ask side → no desired bid → all resting buys stale. + // Desired has only an ask side → no desired bid → all resting buys cancel. const planned = executor.plan([desiredSell(96_000_000n)]); assert.ok(planned); const cancelled = new Set(planned.cancels.map((o) => o.orderId)); - assert.ok(cancelled.has(makeOrderId(1)) && cancelled.has(makeOrderId(2)), "all bids stale"); - assert.ok(cancelled.has(makeOrderId(4)), "worse ask stale"); - assert.ok(!cancelled.has(makeOrderId(3)), "aggressive ask kept"); + assert.ok(cancelled.has(makeOrderId(1)) && cancelled.has(makeOrderId(2)), "all bids cancelled"); + assert.ok(cancelled.has(makeOrderId(4)), "off-grid ask cancelled"); + assert.ok(!cancelled.has(makeOrderId(3)), "on-grid ask kept"); + }); + + it("cancels excess size at a desired price", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); + seedOrder(deps.book, 2, "buy", 95_000_000n, 2n); // aggregate 4 > desired 3 + + const planned = executor.plan([desiredBuy(95_000_000n, 3n)]); + assert.ok(planned); + assert.equal(planned.cancels.length, 1, "one whole order dropped for excess"); + assert.equal(planned.cancels[0].orderId, makeOrderId(2)); + // After cancelling size 2, remaining is 2 < 3 → top up 1. + assert.equal(planned.creates.length, 1); + assert.equal(planned.creates[0].size, 1n); }); }); diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts index 17a61b5..2837528 100644 --- a/market-maker/tests/core/txCoordinator.test.ts +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -13,7 +13,7 @@ function makeLogger(): never { const RECEIPT = { gasUsed: 21_000n, effectiveGasPrice: 1n }; -/** Fake NonceManager: runs the broadcast (triggering venue.multicall) once. */ +/** Fake NonceManager: runs the broadcast (triggering venue.sendCall) once. */ function makeNonce(): { nm: NonceManager; submitCount: () => number } { let count = 0; const nm = { @@ -30,16 +30,19 @@ function makeNonce(): { nm: NonceManager; submitCount: () => number } { } function makeVenue(kind: "perps" | "futures", opts: { fail?: boolean } = {}) { - const batches: `0x${string}`[][] = []; + const calls: `0x${string}`[] = []; const venue = { kind, - multicall: async (calls: `0x${string}`[]) => { + sendCall: async (data: `0x${string}`) => { if (opts.fail) throw new Error(`${kind} boom`); - batches.push(calls); + calls.push(data); return "0xhash" as const; }, + multicall: async () => { + throw new Error("multicall should not be used"); + }, } as unknown as VenueAdapter; - return { venue, batches }; + return { venue, calls }; } function makeMarket( @@ -47,15 +50,30 @@ function makeMarket( id: string, cancels: string[], creates: { price: bigint; im: bigint; size?: bigint }[], + expirationAt?: bigint, ): MarketIntents { const instrument = { id, venue, + expirationAt, encodeCancel: (c: { orderId: `0x${string}` }) => `0xC${c.orderId.slice(2)}` as `0x${string}`, encodeCreate: (o: { price: bigint }) => `0xO${o.price.toString()}` as `0x${string}`, + encodeUpdateOrders: ( + cancelIntents: { orderId: `0x${string}` }[], + orders: { price: bigint; expirationAt?: bigint }[], + ) => { + const cancelPart = cancelIntents.map((c) => c.orderId.slice(2)).join("+"); + const createPart = orders + .map((o) => + o.expirationAt !== undefined + ? `${o.price}@${o.expirationAt}` + : o.price.toString(), + ) + .join(","); + return `0xU${cancelPart}>${createPart}` as `0x${string}`; + }, estimateOrderMargin: (o: { price: bigint }) => creates.find((c) => c.price === o.price)?.im ?? 0n, - // Mirror the futures weighting (cost units = qty) so chunking is exercised. createCallWeight: (o: { size: bigint }) => Number(o.size), } as unknown as InstrumentAdapter; return { @@ -72,8 +90,8 @@ describe("TxCoordinator", () => { const coord = new TxCoordinator(nm, {}, makeLogger()); const { venue } = makeVenue("futures"); const markets = [ - makeMarket(venue, "f1", [], [{ price: 1n, im: 300n }]), - makeMarket(venue, "f2", [], [{ price: 2n, im: 400n }]), + makeMarket(venue, "f1", [], [{ price: 1n, im: 300n }], 100n), + makeMarket(venue, "f2", [], [{ price: 2n, im: 400n }], 200n), ]; await coord.submit(markets, { maxFeePerGas: 1n, @@ -89,8 +107,8 @@ describe("TxCoordinator", () => { it("drops creates but keeps cancels when the gate denies", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, {}, makeLogger()); - const { venue, batches } = makeVenue("futures"); - const markets = [makeMarket(venue, "f1", ["0xdead"], [{ price: 1n, im: 300n }])]; + const { venue, calls } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xdead"], [{ price: 1n, im: 300n }], 100n)]; const res = await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, @@ -100,20 +118,20 @@ describe("TxCoordinator", () => { assert.equal(res.ordersPlaced, 0); assert.equal(res.ordersCancelled, 1); assert.equal(submitCount(), 1); - assert.deepEqual(batches[0], ["0xCdead"]); // only the cancel encoded + assert.deepEqual(calls, ["0xUdead>"]); // updateOrders with cancels only }); - it("merges same-venue markets into one batch, cancels before creates", async () => { + it("merges all same-venue expiries into one updateOrders call", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, {}, makeLogger()); - const { venue, batches } = makeVenue("futures"); + const { venue, calls } = makeVenue("futures"); const markets = [ - makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }]), - makeMarket(venue, "f2", ["0xb"], [{ price: 2n, im: 0n }]), + makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }], 100n), + makeMarket(venue, "f2", ["0xb"], [{ price: 2n, im: 0n }], 200n), ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - assert.equal(submitCount(), 1); // one venue → one tx - assert.deepEqual(batches[0], ["0xCa", "0xCb", "0xO1", "0xO2"]); + assert.equal(submitCount(), 1); + assert.deepEqual(calls, ["0xUa+b>1@100,2@200"]); }); it("isolates venue failures: one venue's revert doesn't block the other", async () => { @@ -132,62 +150,53 @@ describe("TxCoordinator", () => { }); assert.equal(res.errors.length, 1); assert.equal(res.receipts.length, 1); // futures still submitted - assert.equal(futures.batches.length, 1); + assert.equal(futures.calls.length, 1); }); - it("chunks a venue batch that exceeds maxCallsPerTx (unit-weight cancels)", async () => { + it("splits an over-budget venue into cancel-then-create updateOrders txs", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); - const { venue, batches } = makeVenue("futures"); + const { venue, calls } = makeVenue("futures"); + // 3 cancels + create weight 3 → over budget 2 → cancel chunk(s) then create chunk. const markets = [ - makeMarket(venue, "f1", ["0xa", "0xb", "0xc", "0xd", "0xe"], []), + makeMarket( + venue, + "f1", + ["0xa", "0xb", "0xc"], + [{ price: 1n, im: 0n, size: 3n }], + 100n, + ), ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - assert.equal(submitCount(), 3); // 5 cancels @ weight 1 / budget 2 = 3 chunks - assert.deepEqual(batches.map((b) => b.length), [2, 2, 1]); + assert.equal(submitCount(), 3); + assert.deepEqual(calls, ["0xUa+b>", "0xUc>", "0xU>1@100"]); }); - it("chunks by weighted cost units (futures qty), not raw call count", async () => { + it("keeps under-budget cancel+create in one updateOrders across expiries", async () => { const { nm, submitCount } = makeNonce(); - const coord = new TxCoordinator(nm, { maxCallsPerTx: 10 }, makeLogger()); - const { venue, batches } = makeVenue("futures"); - // Weights 6, 6, 3: budget 10 → [6] | [6, 3]. Three calls, but two txs. + const coord = new TxCoordinator(nm, { maxCallsPerTx: 20 }, makeLogger()); + const { venue, calls } = makeVenue("futures"); const markets = [ - makeMarket(venue, "f1", [], [ - { price: 1n, im: 0n, size: 6n }, - { price: 2n, im: 0n, size: 6n }, - { price: 3n, im: 0n, size: 3n }, - ]), + makeMarket(venue, "f1", ["0xz"], [{ price: 1n, im: 0n, size: 5n }], 100n), + makeMarket(venue, "f2", [], [{ price: 2n, im: 0n, size: 1n }], 200n), ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - assert.equal(submitCount(), 2); - assert.deepEqual(batches.map((b) => b.length), [1, 2]); - }); - - it("sends a single over-budget call alone rather than dropping it", async () => { - const { nm, submitCount } = makeNonce(); - const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); - const { venue, batches } = makeVenue("futures"); - // One create of qty 5 > budget 2 → its own chunk; a trailing cancel packs after. - const markets = [makeMarket(venue, "f1", ["0xz"], [{ price: 1n, im: 0n, size: 5n }])]; - await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - // cancel (w1) then create (w5): [0xCz] fills to 1, +5 > 2 → flush, [0xO1] alone. - assert.equal(submitCount(), 2); - assert.deepEqual(batches.map((b) => b.length), [1, 1]); + assert.equal(submitCount(), 1); + assert.deepEqual(calls, ["0xUz>1@100,2@200"]); }); it("dry run submits nothing but reports intended counts", async () => { const { nm, submitCount } = makeNonce(); const coord = new TxCoordinator(nm, {}, makeLogger()); - const { venue, batches } = makeVenue("futures"); - const markets = [makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }])]; + const { venue, calls } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }], 100n)]; const res = await coord.submit(markets, { maxFeePerGas: 1n, dryRun: true, canPlace: async () => true, }); assert.equal(submitCount(), 0); - assert.equal(batches.length, 0); + assert.equal(calls.length, 0); assert.equal(res.ordersCancelled, 1); assert.equal(res.ordersPlaced, 1); }); From 5c217cc27caaf06d2389bea67d9e8451392a483f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 21 Jul 2026 16:47:14 +0200 Subject: [PATCH 101/180] docs: add AI agent discoverability plan Co-authored-by: Cursor --- .../ai_agent_discoverability_a73abde3.plan.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/ai_agent_discoverability_a73abde3.plan.md diff --git a/docs/ai_agent_discoverability_a73abde3.plan.md b/docs/ai_agent_discoverability_a73abde3.plan.md new file mode 100644 index 0000000..058c388 --- /dev/null +++ b/docs/ai_agent_discoverability_a73abde3.plan.md @@ -0,0 +1,120 @@ +--- +name: AI Agent Discoverability +overview: "Make the four Titan protocol repos (perps, futures-marketplace, collateral-margin, hashprice-oracle) and the separate landing website discoverable and usable by AI agents through a layered stack: static agent context (AGENTS.md, rules), doc discoverability (llms.txt + markdown), MCP servers grounded in blockchain RPC calls and subgraph GraphQL endpoints, skills, and GitHub/org metadata." +todos: + - id: agents-md + content: Add root + per-package AGENTS.md and .cursor/rules to perps, futures-marketplace, collateral-margin, hashprice-oracle (seed from existing READMEs; futures needs a full README rewrite) + status: pending + - id: llms-txt + content: Add llms.txt (+ optional llms-full.txt) to each repo and a 'For AI Agents' README section listing contract addresses, ABI paths, and subgraph GraphQL URLs + status: pending + - id: mcp-servers + content: Build per-repo mcp/ packages (viem + MCP SDK) exposing read/simulate/build-unsigned-tx tools over contract RPC + subgraph; hashprice-oracle first, then perps, futures, collateral-margin + status: pending + - id: mcp-aggregator + content: Add optional titan-mcp aggregator, MCP install snippets in READMEs, and list servers on the official MCP Registry + directories + status: pending + - id: landing-site + content: "Landing website: llms.txt/llms-full.txt, AI-crawler robots.txt, schema.org JSON-LD + sitemap, markdown endpoints, and a machine-readable 'For AI agents' manifest (addresses/subgraphs/MCP)" + status: pending + - id: skills-org + content: Add optional .cursor/skills for repeatable workflows and a Lumerin-protocol/.github profile repo with an org-wide ecosystem map + status: pending +isProject: false +--- + +# AI Agent Discoverability Plan + +Goal: make the four repos and the landing site legible and actionable to AI agents. The interaction substrate is already agent-friendly - **contracts implement standard interfaces and export ABIs**, and **subgraph/indexer GraphQL URLs are queryable**. MCP wraps exactly those two surfaces (read + simulate + build-unsigned-tx), so no new custodial infrastructure is required. + +## Layered model (applied to every surface) + +```mermaid +flowchart TD + Agent["AI Agent (Cursor / Claude / Codex)"] + subgraph Static["Static context"] + AG["AGENTS.md + .cursor/rules"] + LL["llms.txt + markdown docs"] + RB["robots.txt / GitHub metadata"] + end + subgraph Live["Live capabilities (MCP)"] + RPC["Contract reads / simulate (viem + ABIs)"] + SG["Subgraph GraphQL queries"] + TX["build_unsigned_tx (calldata only)"] + end + Agent --> Static + Agent --> Live + RPC --> Chain["Arbitrum RPC"] + SG --> Indexer["The Graph endpoints"] +``` + +## 1. Shared conventions (all four repos get these) + +- **Root `AGENTS.md`**: canonical agent context. Seed from existing READMEs (already contain build/test commands + architecture). Sections: project purpose, package map, per-package build/test commands, code conventions, key contract addresses + subgraph URLs, "what agents can query live" pointer to MCP. +- **Per-package `AGENTS.md`** for non-trivial packages (`contracts/`, `indexer/`, `keeper/`, `market-maker/`, `mcp/`) with the local commands + gotchas. +- **`.cursor/rules/`**: perps already has [.cursor/rules/project-conventions.mdc](perps/.cursor/rules/project-conventions.mdc). Replicate an equivalent rule in the other three repos (glob-scoped conventions), and have `AGENTS.md` reference it as the source of truth so both Cursor and non-Cursor agents converge. +- **`llms.txt`** at repo root: curated map linking the README, docs, ABIs, and subgraph schema. Optional `llms-full.txt` for single-fetch ingestion. +- **README "For AI Agents" section**: contract addresses per network, ABI path, subgraph GraphQL URL, and the MCP install snippet. This is the highest-leverage machine-usable anchor given the RPC+indexer interaction model. +- **GitHub repo metadata**: description, topics/tags (e.g. `defi`, `perps`, `arbitrum`, `the-graph`, `hashprice`, `mcp`), and a populated About panel. + +## 2. Per-repo specifics + +### perps ([perps/](perps)) + +- Strongest starting point (rich [README.md](perps/README.md), existing cursor rule, GitBook docs). +- Convert the existing rule content into a root `AGENTS.md`; keep the rule file. +- `llms.txt` linking README + `docs/gitbook/*` + `contracts/abi/abi.ts` + subgraph schema. +- MCP tools (read-first): `get_market_price`, `get_orderbook` (subgraph price levels), `get_user_position`, `get_user_collateral`, `get_funding`, `get_trades` (subgraph), plus `simulate_order` and `build_create_order_tx` / `build_deposit_tx` (return calldata, no signing). + +### futures-marketplace ([futures-marketplace/](futures-marketplace)) + +- Weakest README (2 lines) - biggest lift. Rewrite [README.md](futures-marketplace/README.md) to match perps depth (architecture, packages, commands, tech stack). +- Add root `AGENTS.md` + `.cursor/rules`. +- `docs/` already exists (`01.Overview`..`06.Event-Design-Spec`) - add `llms.txt` mapping them. +- Note the two web surfaces: the trading `ui/` (Vite React, has [ui/public/robots.txt](futures-marketplace/ui/public/robots.txt)) is distinct from the landing site (section 5). +- MCP tools: contract specs, delivery/settlement status, orderbook + margin reads via subgraph, `build_*_tx` for order/deposit. + +### collateral-margin ([collateral-margin/](collateral-margin)) + +- Rich [README.md](collateral-margin/README.md) already. Add root `AGENTS.md` + `.cursor/rules` + `llms.txt` (link `docs/*` design notes). +- MCP tools (read-only): `compute_portfolio_im`, `compute_portfolio_mm` (call `PortfolioMarginEngine`), `get_vault_balance` (`CollateralVault`), `get_portfolio_risk` (aggregate net delta/gamma/vega across adapters). This is the natural "risk oracle" MCP surface. + +### hashprice-oracle ([hashprice-oracle/](hashprice-oracle)) + +- Rich [README.md](hashprice-oracle/README.md); has empty `.ai-docs/`. Add root `AGENTS.md` + `.cursor/rules` + `llms.txt` (link `docs/BLOCK_VALIDATION.md`). +- MCP tools: `get_hashprice_btc` / `get_hashprice_usd` (`latestRoundData` via `AggregatorV3Interface`), `get_oracle_status` (on-chain height vs BTC tip + staleness), `query_hashprice_history` (subgraph hourly/daily). Highest external reuse value (Chainlink-compatible feed). + +## 3. MCP design (grounded in RPC + subgraph) + +- **Placement**: each repo ships an `mcp/` package (TypeScript, viem, `@modelcontextprotocol/sdk`), reusing already-exported ABIs (`contracts/abi/`) and the subgraph endpoint. Independent per repo, matching the multi-repo layout. +- **Optional aggregator**: a thin `titan-mcp` that re-exports all four (best single-config UX for agents/users). Recommended once per-repo servers exist. +- **Safety model**: expose **read** and **simulate** freely; for writes, only `build_*_tx` returning unsigned calldata + a viem `simulate` result. No private keys, no signing in MCP. +- **Config surface**: `RPC_URL` per network + `SUBGRAPH_URL` env vars; contract addresses baked from a shared `addresses.json`. +- **Transport**: stdio for local dev; document a remote HTTP/SSE option later for hosted use. +- **Discoverability**: README install snippet (`.cursor/mcp.json` entry), and list servers on the official MCP Registry + directories (Smithery/Glama/PulseMCP). + +## 4. Skills (optional, repeatable workflows) + +- `.cursor/skills/` (or `skills/`) with `SKILL.md` files for common multi-step ops: deploy/redeploy subgraph, run e2e stack, add a new market, regenerate + copy ABIs. Descriptions written as "use this when...". Low priority vs. sections 1-3. + +## 5. Landing website (separate, not in workspace - framework-agnostic) + +- **`/llms.txt`** (curated) + **`/llms-full.txt`** (inlined) at site root, linking product docs, contract addresses, subgraph URLs, and MCP configs. +- **Markdown endpoints**: serve `.md` mirrors of key pages (or content negotiation) so agents fetch clean content. +- **`/robots.txt`** with explicit AI-crawler policy: allow/deny `GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended`, `CCBot`, etc., plus `Sitemap:`. +- **Structured data**: schema.org JSON-LD (`Organization`, `SoftwareApplication`, `FAQPage`), OpenGraph, `sitemap.xml`. +- **"For AI agents" page + machine-readable manifest** (JSON): per-network contract addresses, ABI links, subgraph GraphQL URLs, and MCP endpoints/config - the single canonical entry point tying the whole ecosystem together. +- **`/.well-known/`** manifest if/when remote MCP is hosted. + +## 6. GitHub / org-level + +- `Lumerin-protocol/.github` profile repo with org-wide agent guidance and a top-level ecosystem map linking all repos + docs + MCP. +- Consistent repo descriptions, topics, and pinned repos. + +## Suggested rollout order + +1. `AGENTS.md` + `.cursor/rules` + README fixes (futures first) across all four repos. +2. `llms.txt` per repo + "For AI Agents" README sections with addresses/subgraph URLs. +3. MCP: hashprice-oracle first (simplest, highest reuse) -> perps -> futures -> collateral-margin; then optional aggregator + registry listing. +4. Landing site: llms.txt + robots.txt + machine-readable manifest + markdown endpoints. +5. Skills + org `.github` repo. From d84b0065835ad6d919dfe8ba35291b7971e1bf26 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 13:03:32 +0200 Subject: [PATCH 102/180] chore(deps): pin futures/perps to updateOrders liquidation SHAs Point keeper ABI + MM contracts + integration checkout refs at futures #223 (ac6b2d8f) and perps #78 (7df05701) so CI compiles liquidateOrders(user, ids) and updateOrders. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 8 ++++---- keeper/package.json | 4 ++-- keeper/pnpm-lock.yaml | 20 ++++++++++---------- market-maker/package.json | 4 ++-- market-maker/pnpm-lock.yaml | 28 ++++++++++++++-------------- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 7d3516c..6ce7181 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -90,16 +90,16 @@ jobs: uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - ref: c8078b9e430df8a5ec8bc64865d692b38cc69d8c + # updateOrders + liquidateOrders(user, ids) (PR #78). + ref: 7df057016ed148058ba2916c441e951a7b56a08c path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - # Futures 3.0 aggregate positions (PR Lumerin-protocol/futures-marketplace#215). - # After that merges to dev, retarget this pin to the merge commit / dev tip. - ref: 2e2c1b599eec37f2f27973e78bafaf0870e1df7a + # updateOrders + liquidateOrders(user, ids) (PR #223). + ref: ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/package.json b/keeper/package.json index eb1d8b2..306a216 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,8 +21,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#2e2c1b599eec37f2f27973e78bafaf0870e1df7a&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 56b6665..6335b75 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -19,11 +19,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#2e2c1b599eec37f2f27973e78bafaf0870e1df7a&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -149,8 +149,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/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} version: 0.0.0 end-of-stream@1.4.5: @@ -165,8 +165,8 @@ 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/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} version: 0.0.0 help-me@5.0.0: @@ -351,7 +351,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -363,7 +363,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/market-maker/package.json b/market-maker/package.json index 1dca0ae..73fdc79 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -37,9 +37,9 @@ "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#feat/futures-3-aggregate-positions&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 61edf62..442f502 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -27,14 +27,14 @@ importers: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#feat/futures-3-aggregate-positions&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#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 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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)) + specifier: github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1245,8 +1245,8 @@ packages: resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9} version: 1.0.0 color-convert@2.0.1: @@ -1332,8 +1332,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} version: 1.0.0 engines: {node: 24.x} @@ -1546,8 +1546,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} version: 1.0.0 engines: {node: 24.x} @@ -4484,7 +4484,7 @@ snapshots: collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: {} color-convert@2.0.1: dependencies: @@ -4567,7 +4567,7 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#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)): + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 @@ -4850,7 +4850,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/2e2c1b599eec37f2f27973e78bafaf0870e1df7a#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#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 @@ -4859,7 +4859,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/fe34a2bd35eb12333a57dac83f65807bfbd2956a + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 From 872ce18a9d4cb14f684b6d6013de584cef2d52f9 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 13:08:27 +0200 Subject: [PATCH 103/180] fix(keeper): interleave multi-expiry close legs for stop-when-healthy Coalescing unit closes by expiry drained the first book before the second, so liquidatePositions stopped mid-batch once healthy. Emit 1-qty legs in round-robin order so a truncated prefix stays balanced across expirations. Co-authored-by: Cursor --- keeper/src/predict/solve.ts | 8 +++++++- keeper/tests/predict/solveTarget.test.ts | 16 ++++++++++++---- keeper/tests/venues/reduceToTarget.test.ts | 9 +++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index cbeb0b6..f3f4bbf 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -325,7 +325,13 @@ export function solveFuturesClosesToTarget( closeQty: abs(p.netQuantity), })); } - return coalesceUnitPrefix(unitSequence, bestPrefix); + // Emit 1-qty legs in round-robin order (not coalesced/sorted by expiry). + // `liquidatePositions` stops once healthy; coalescing into [A:N, B:M] would + // drain A first and skip B. Interleaved unit legs keep the prefix balanced. + return unitSequence.slice(0, bestPrefix).map((expirationAt) => ({ + expirationAt, + closeQty: 1n, + })); } /** diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts index efcbbf3..c1247a1 100644 --- a/keeper/tests/predict/solveTarget.test.ts +++ b/keeper/tests/predict/solveTarget.test.ts @@ -135,8 +135,12 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); assert.ok(totalCloseQty(closes) > 1n); - const countA = closes.find((c) => c.expirationAt === EXPIRY_A)?.closeQty ?? 0n; - const countB = closes.find((c) => c.expirationAt === EXPIRY_B)?.closeQty ?? 0n; + const countA = closes + .filter((c) => c.expirationAt === EXPIRY_A) + .reduce((s, c) => s + c.closeQty, 0n); + const countB = closes + .filter((c) => c.expirationAt === EXPIRY_B) + .reduce((s, c) => s + c.closeQty, 0n); assert.ok(countA >= 1n && countB >= 1n, `both expirations must be reduced (A=${countA}, B=${countB})`); assert.ok( countA - countB <= 1n && countB - countA <= 1n, @@ -161,8 +165,12 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { }); const P = P_MODERATE; const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); - const countA = closes.find((c) => c.expirationAt === EXPIRY_A)?.closeQty ?? 0n; - const countB = closes.find((c) => c.expirationAt === EXPIRY_B)?.closeQty ?? 0n; + const countA = closes + .filter((c) => c.expirationAt === EXPIRY_A) + .reduce((s, c) => s + c.closeQty, 0n); + const countB = closes + .filter((c) => c.expirationAt === EXPIRY_B) + .reduce((s, c) => s + c.closeQty, 0n); // A is twice B → roughly 2:1 close ratio when both are touched. if (countA > 0n && countB > 0n) { assert.ok(countA >= countB, `A=${countA} should close at least as many as B=${countB}`); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index 160018e..d85b1ea 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -102,10 +102,11 @@ describe("futures venue: reduceToTarget", () => { bigint[], ]; assert.equal(participant, USER); - assert.equal(expirationAts.length, 1); - assert.equal(expirationAts[0], EXPIRY); - assert.ok(closeQtys[0]! > 0n && closeQtys[0]! < 12n, "strict subset of contracts"); - assert.ok("feeEarned" in outcome && outcome.positionsClosed === Number(closeQtys[0])); + assert.ok(expirationAts.length >= 1); + assert.ok(expirationAts.every((e) => e === EXPIRY)); + const totalClose = closeQtys.reduce((s, q) => s + q, 0n); + assert.ok(totalClose > 0n && totalClose < 12n, "strict subset of contracts"); + assert.ok("feeEarned" in outcome && outcome.positionsClosed === Number(totalClose)); }); it("caps the batch to maxLotsPerLiquidationTx (expiry-leg chunking)", async () => { From 79fa56d7eb019165221770f81b35fdb329d2862d Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 14:49:26 +0200 Subject: [PATCH 104/180] feat(mm): unify geometric-taper sizing and decay far futures expiries Use front-heavy taper on perps as well as futures, deepen futures ladders, and scale quote size down by expiry rank so nearer books keep more size. Co-authored-by: Cursor --- market-maker/README.md | 4 +- market-maker/configs/futures.dev.yml | 2 +- market-maker/configs/futures.local.yml | 2 +- market-maker/configs/futures.prd.yml | 2 +- market-maker/configs/futures.stg.yml | 2 +- market-maker/configs/perps.dev.yml | 3 +- market-maker/configs/perps.local.yml | 3 +- market-maker/configs/perps.prd.yml | 3 +- market-maker/configs/perps.stg.yml | 3 +- market-maker/configs/portfolio.dev.yml | 19 ++--- market-maker/configs/portfolio.local.yml | 12 +++- market-maker/configs/portfolio.prd.yml | 19 ++--- market-maker/configs/portfolio.stg.yml | 18 ++--- market-maker/schemas/futures.json | 16 +++++ market-maker/schemas/perps.json | 26 +++++-- market-maker/schemas/portfolio.json | 42 +++++++++-- market-maker/src/apps/futures/config.ts | 11 +++ market-maker/src/apps/perps/config.ts | 27 ++++--- market-maker/src/apps/perps/main.ts | 3 +- market-maker/src/apps/portfolio/config.ts | 2 + market-maker/src/apps/portfolio/main.ts | 71 +++++++++++++++---- market-maker/src/core/quoter.ts | 13 +++- market-maker/src/core/sizing/expiryDecay.ts | 37 ++++++++++ market-maker/tests/apps/perps/config.test.ts | 12 ++-- .../tests/apps/perps/main.smoke.test.ts | 3 +- .../tests/apps/portfolio/config.test.ts | 23 +++--- .../tests/core/sizing/expiryDecay.test.ts | 40 +++++++++++ 27 files changed, 326 insertions(+), 92 deletions(-) create mode 100644 market-maker/src/core/sizing/expiryDecay.ts create mode 100644 market-maker/tests/core/sizing/expiryDecay.test.ts diff --git a/market-maker/README.md b/market-maker/README.md index c080962..436434f 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -87,8 +87,8 @@ graph LR controlled by `inventorySkewGamma` and `maxSkewTicks` - **Gas spike penalty**: widens spread proportionally when gas exceeds median by `gasSpikeThresholdPct` -- **Level sizing**: geometric taper — outer levels are progressively - larger by `levelSizeRatio` +- **Level sizing**: geometric taper — front level is largest; each + deeper level is `taperRatio` × the previous ### Risk controls diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 7b09516..bb5212f 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -46,7 +46,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: 8 # venue-native (contracts base units) - numLevelsPerSide: 4 + numLevelsPerSide: 6 taperRatio: 0.6 risk: diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 56986e3..e228d9f 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -37,7 +37,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "10000000" # venue-native (contracts base units) - numLevelsPerSide: 3 + numLevelsPerSide: 5 taperRatio: 0.6 risk: diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index fc64a22..2b718a8 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -40,7 +40,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "500000000" # venue-native (contracts base units) - numLevelsPerSide: 4 + numLevelsPerSide: 6 taperRatio: 0.6 risk: diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index bfad7a9..6d9c715 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -40,7 +40,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "100000000" # venue-native (contracts base units) - numLevelsPerSide: 4 + numLevelsPerSide: 6 taperRatio: 0.6 risk: diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index efc37d5..6539f5f 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -44,9 +44,10 @@ pricing: maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "1000000" # venue-native units (hashrate base) numLevelsPerSide: 5 + taperRatio: 0.6 risk: maxPositionSize: 50 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index 9ddbcb9..11687e6 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -39,9 +39,10 @@ pricing: maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "100000" # venue-native units (hashrate base) numLevelsPerSide: 3 + taperRatio: 0.6 risk: # All *Usd fields are USD (decimals OK). Loader converts to 6-dec USDC. diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index 6db70d0..efb7697 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -41,9 +41,10 @@ pricing: maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "10000000" # venue-native units (hashrate base) numLevelsPerSide: 5 + taperRatio: 0.6 risk: maxPositionSize: 1000 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index 0774559..a7efcf3 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -40,9 +40,10 @@ pricing: maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "1000000" # venue-native units (hashrate base) numLevelsPerSide: 5 + taperRatio: 0.6 risk: maxPositionSize: 50 diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index e204a31..c493beb 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -48,9 +48,10 @@ venues: inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "1000000" # venue-native (perps hashrate base units) numLevelsPerSide: 5 + taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} @@ -68,15 +69,15 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, - # and every qty unit becomes a distinct resting order. The quoter spreads - # baseQuantity × numLevelsPerSide across the ladder, so total resting - # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 - # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 - # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. - baseQuantity: 4 # venue-native (contracts base units) - numLevelsPerSide: 3 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # baseQuantity×numLevels/1e6 = 5. Split across `count` expiries; base=4 + # is the integer floor that keeps all 5 taper levels non-zero + # ([8,5,3,1,1]). Order count is 2×levels×expiries (=30), not ×qty. + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 5 taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 0.6 # Shared portfolio-wide budget across every market. risk: diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index 4428b8f..2566f39 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -39,9 +39,10 @@ venues: inventorySkewGamma: 1.0 maxSkewTicks: 5 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "10000000" # venue-native (perps hashrate base units) numLevelsPerSide: 3 + taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} @@ -59,9 +60,14 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - baseQuantity: "10000000" # venue-native (contracts base units) - numLevelsPerSide: 3 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # 10e6×3/1e6 = 30. Split across 3 expiries: 4×5×3 = 60 (taper + # [8,5,3,1,1] keeps all 5 levels non-zero). + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 5 taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 0.6 # Shared portfolio-wide budget across every market. risk: diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index 63a22c3..37736e7 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -44,9 +44,10 @@ venues: inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "10000000" # venue-native (perps hashrate base units) numLevelsPerSide: 5 + taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} @@ -64,14 +65,16 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, - # and every qty unit becomes a distinct resting order. Total resting - # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 - # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 - # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. - baseQuantity: 4 # venue-native (contracts base units) - numLevelsPerSide: 3 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # baseQuantity×numLevels/1e6 = 50. Split across `count` expiries: + # futures_base × futures_levels × expiries ≈ perps_total + # 4 × 5 × 3 = 60 ≈ 50. Taper [8,5,3,1,1] per book. Order count is + # 2×levels×expiries (=30), not ×qty. + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 5 taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 0.6 # Shared portfolio-wide budget across every market. risk: diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml index 8973eeb..6a8ff35 100644 --- a/market-maker/configs/portfolio.stg.yml +++ b/market-maker/configs/portfolio.stg.yml @@ -44,9 +44,10 @@ venues: inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "1000000" # venue-native (perps hashrate base units) numLevelsPerSide: 5 + taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} @@ -64,14 +65,15 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # Futures orders are capped on-chain at MAX_ORDERS_PER_PARTICIPANT = 100, - # and every qty unit becomes a distinct resting order. Total resting - # orders ≈ 2 sides × baseQuantity × numLevelsPerSide × expiries. At 3 - # expiries × 3 levels that is 18 × baseQuantity, so keep baseQuantity ≤ 5 - # (4 ⇒ ~66 worst-case) to stay under 100 with roll-overlap headroom. - baseQuantity: 4 # venue-native (contracts base units) - numLevelsPerSide: 3 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # baseQuantity×numLevels/1e6 = 5. Split across `count` expiries; base=4 + # is the integer floor that keeps all 5 taper levels non-zero + # ([8,5,3,1,1]). Order count is 2×levels×expiries (=30), not ×qty. + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 5 taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 0.6 # Shared portfolio-wide budget across every market. risk: diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 9794247..96693f5 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -330,6 +330,22 @@ } ], "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." + }, + "expirySizeDecay": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "maximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Per-expiry size multiplier for further delivery dates (nearest-first). Expiry i gets baseQuantity × expirySizeDecay^i. 1 disables. Portfolio-only effect when multiple expiries are quoted.", + "default": 0.6 } } }, diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 134b5f3..e5ea99f 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -251,21 +251,22 @@ }, "sizing": { "additionalProperties": false, - "description": "Linear-ladder sizing parameters.", + "description": "Geometric-taper sizing parameters.", "type": "object", "required": [ "strategy", "baseQuantity", - "numLevelsPerSide" + "numLevelsPerSide", + "taperRatio" ], "properties": { "strategy": { - "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", - "const": "linear", + "description": "Sizing strategy. Perps lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", "type": "string" }, "baseQuantity": { - "description": "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "description": "Per-level size unit in venue-native units (perps: hashrate base). Total per-side budget is baseQuantity × numLevelsPerSide, distributed via taperRatio. Use a string for values > 2^53.", "anyOf": [ { "anyOf": [ @@ -307,6 +308,21 @@ } ], "description": "Number of price levels quoted per side." + }, + "taperRatio": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." } } }, diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 38c29fc..d399cb1 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -282,21 +282,22 @@ }, "sizing": { "additionalProperties": false, - "description": "Linear-ladder sizing parameters.", + "description": "Geometric-taper sizing parameters.", "type": "object", "required": [ "strategy", "baseQuantity", - "numLevelsPerSide" + "numLevelsPerSide", + "taperRatio" ], "properties": { "strategy": { - "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", - "const": "linear", + "description": "Sizing strategy. Perps lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", "type": "string" }, "baseQuantity": { - "description": "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "description": "Per-level size unit in venue-native units (perps: hashrate base). Total per-side budget is baseQuantity × numLevelsPerSide, distributed via taperRatio. Use a string for values > 2^53.", "anyOf": [ { "anyOf": [ @@ -338,6 +339,21 @@ } ], "description": "Number of price levels quoted per side." + }, + "taperRatio": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." } } } @@ -620,6 +636,22 @@ } ], "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous." + }, + "expirySizeDecay": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "maximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Per-expiry size multiplier for further delivery dates (nearest-first). Expiry i gets baseQuantity × expirySizeDecay^i. 1 disables. Portfolio-only effect when multiple expiries are quoted.", + "default": 0.6 } } } diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index ba5485a..f50fe82 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -115,6 +115,16 @@ export const futuresSizingSchema = Type.Object( description: "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous.", }), + expirySizeDecay: Type.Optional( + Type.Number({ + exclusiveMinimum: 0, + maximum: 1, + default: 0.6, + description: + "Per-expiry size multiplier for further delivery dates (nearest-first). " + + "Expiry i gets baseQuantity × expirySizeDecay^i. 1 disables. Portfolio-only effect when multiple expiries are quoted.", + }), + ), }, { ...Closed, description: "Geometric-taper sizing parameters." }, ); @@ -213,6 +223,7 @@ export function loadFuturesConfig( String(raw.sizing.baseQuantity), "sizing.baseQuantity", ), + expirySizeDecay: raw.sizing.expirySizeDecay ?? 0.6, }, }), validate: (cfg) => { diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index cf88fe2..700d76e 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -26,9 +26,9 @@ import { ConfigError } from "../../core/errors.ts"; * Perps app config schema. * * Pricing locked to "effective-spread" (symmetric, limit-matched) — that's - * the strategy that fits the perps order book. Sizing locked to "linear" - * for the same reason: deeper levels are larger because they only fill - * after the shallower ones do. + * the strategy that fits the perps order book. Sizing locked to + * "geometric-taper" so the front level (highest fill prob) is the largest, + * matching futures. * * No runtime ternaries — the schema demands the right shape, the loader * rejects mismatches, and the Quoter / Executor read the static values. @@ -84,28 +84,35 @@ export const perpsPricingSchema = Type.Object( { ...Closed, description: "Effective-spread pricing parameters." }, ); -// `baseQuantity` is venue-native (perps: hashrate base units). It's a bigint -// expressed as a decimal string; numbers are accepted but use strings if -// values exceed Number.MAX_SAFE_INTEGER. +// `baseQuantity` is venue-native (perps: hashrate base units). Bigint +// expressed as a decimal string; numbers accepted but use strings if values +// exceed Number.MAX_SAFE_INTEGER. Quoter distributes +// `baseQuantity × numLevelsPerSide` across the ladder via taperRatio. export const perpsSizingSchema = Type.Object( { - strategy: Type.Literal("linear", { + strategy: Type.Literal("geometric-taper", { description: - "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) × baseQuantity).", + "Sizing strategy. Perps lock to 'geometric-taper' (front level largest, decays by taperRatio).", }), baseQuantity: Type.Union( [Type.String({ pattern: "^\\d+$" }), Type.Number()], { description: - "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "Per-level size unit in venue-native units (perps: hashrate base). Total per-side budget is baseQuantity × numLevelsPerSide, distributed via taperRatio. Use a string for values > 2^53.", }, ), numLevelsPerSide: Type.Number({ minimum: 1, description: "Number of price levels quoted per side.", }), + taperRatio: Type.Number({ + exclusiveMinimum: 0, + exclusiveMaximum: 1, + description: + "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio × the previous.", + }), }, - { ...Closed, description: "Linear-ladder sizing parameters." }, + { ...Closed, description: "Geometric-taper sizing parameters." }, ); export const perpsRootSchema = Type.Object( diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 89a2950..4181038 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -123,9 +123,10 @@ async function main(): Promise { gasPenaltyBps: config.risk.gasPenaltyBps, }, sizing: { - strategy: "linear", + strategy: "geometric-taper", baseQuantity: config.sizing.baseQuantity, numLevelsPerSide: config.sizing.numLevelsPerSide, + taperRatio: config.sizing.taperRatio, }, maxSkewTicks: config.pricing.maxSkewTicks, levelSpacingTicks: config.timing.levelSpacingTicks, diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts index b6d2c00..158c040 100644 --- a/market-maker/src/apps/portfolio/config.ts +++ b/market-maker/src/apps/portfolio/config.ts @@ -310,6 +310,8 @@ function parseVenue(raw: RawVenue): ParsedVenue { sizing: { ...raw.sizing, baseQuantity: configBigint(String(raw.sizing.baseQuantity), "venue.sizing.baseQuantity"), + // Default may be stripped from the venues union schema (AJV combinator rule). + expirySizeDecay: raw.sizing.expirySizeDecay ?? 0.6, }, }; } diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index 94aacb7..5769857 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -30,6 +30,7 @@ import { type ParsedVenue, type PortfolioMakerConfig, } from "./config.ts"; +import { expirySizeScale } from "../../core/sizing/expiryDecay.ts"; /** Shared context passed to every market factory. */ interface BuildContext { @@ -76,15 +77,7 @@ function quoterPricing(venue: ParsedVenue, gasPenaltyBps: number): QuoterConfig[ } function quoterSizing(venue: ParsedVenue): QuoterConfig["sizing"] { - if (venue.kind === "perps") { - const s = (venue as ParsedPerpsVenue).sizing; - return { - strategy: "linear", - baseQuantity: s.baseQuantity, - numLevelsPerSide: s.numLevelsPerSide, - }; - } - const s = (venue as ParsedFuturesVenue).sizing; + const s = venue.sizing; return { strategy: "geometric-taper", baseQuantity: s.baseQuantity, @@ -93,11 +86,33 @@ function quoterSizing(venue: ParsedVenue): QuoterConfig["sizing"] { }; } +/** + * Apply nearest-first expiry size decay to all futures markets in `markets`. + * Index 0 keeps full size; index i gets `expirySizeDecay^i`. + */ +function syncFuturesExpirySizeScales( + markets: MarketRuntime[], + futuresCfg: ParsedFuturesVenue, +): void { + const decay = futuresCfg.sizing.expirySizeDecay ?? 0.6; + const futures = markets + .filter((m) => m.id.startsWith("futures:")) + .sort((a, b) => { + const ea = (a.instrument as { expirationAt?: bigint }).expirationAt ?? 0n; + const eb = (b.instrument as { expirationAt?: bigint }).expirationAt ?? 0n; + return ea < eb ? -1 : ea > eb ? 1 : 0; + }); + for (let i = 0; i < futures.length; i++) { + futures[i].quoter.setSizeScale(expirySizeScale(i, decay)); + } +} + /** Build a fully-wired (but not-yet-started) market for an instrument. */ function buildMarket( instrument: InstrumentAdapter, venue: ParsedVenue, ctx: BuildContext, + opts: { expiryIndex?: number } = {}, ): MarketRuntime { const { config, gas, risk, logger } = ctx; const oracle = buildOracle(instrument, ctx); @@ -126,6 +141,11 @@ function buildMarket( risk, logger, ); + if (venue.kind === "futures" && opts.expiryIndex !== undefined) { + quoter.setSizeScale( + expirySizeScale(opts.expiryIndex, venue.sizing.expirySizeDecay ?? 0.6), + ); + } const executor = new OrderExecutor( instrument, { @@ -249,11 +269,16 @@ async function main(): Promise { for (let i = 0; i < config.venues.length; i++) { const vCfg = config.venues[i]; const adapter = venueAdapters[i]; - const instruments = await adapter.listInstruments(); - for (const instrument of instruments) { - markets.push(buildMarket(instrument, vCfg, ctx)); + const instruments = await adapter.listInstruments(); // futures: nearest-first + for (let j = 0; j < instruments.length; j++) { + markets.push( + buildMarket(instruments[j], vCfg, ctx, { + expiryIndex: vCfg.kind === "futures" ? j : undefined, + }), + ); } } + if (futuresCfg) syncFuturesExpirySizeScales(markets, futuresCfg); logger.info({ count: markets.length }, "built initial markets"); // ── Centralized submission ─────────────────────────────────────────────── @@ -290,10 +315,26 @@ async function main(): Promise { // ── Roll: reconcile futures expiries against the live venue selection ──── const onRoll: RollFn | undefined = futuresVenue && futuresCfg - ? async () => { - const { added, dropped } = await futuresVenue.resolveMarkets(); + ? async (current) => { + const { active, added, dropped } = await futuresVenue.resolveMarkets(); + const indexById = new Map(active.map((inst, idx) => [inst.id, idx])); + // Survivors keep their MarketRuntime; refresh size scales for the new + // nearest-first ranking before new markets are spliced in. + const surviving = current.filter((m) => !dropped.some((d) => d.id === m.id)); + for (const m of surviving) { + const idx = indexById.get(m.id); + if (idx !== undefined) { + m.quoter.setSizeScale( + expirySizeScale(idx, futuresCfg.sizing.expirySizeDecay ?? 0.6), + ); + } + } return { - add: added.map((inst) => buildMarket(inst, futuresCfg, ctx)), + add: added.map((inst) => + buildMarket(inst, futuresCfg, ctx, { + expiryIndex: indexById.get(inst.id) ?? 0, + }), + ), removeIds: dropped.map((inst) => inst.id), }; } diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index d7fb589..28ad35b 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -10,6 +10,7 @@ import { computeMidQuote, type EffectiveSpreadConfig } from "./pricing/effective import { computeReservationMidQuote, type ReservationPriceConfig } from "./pricing/reservationPrice.ts"; import { linearSizes } from "./sizing/linear.ts"; import { geometricTaperSizes } from "./sizing/geometricTaper.ts"; +import { scaleBaseQuantity } from "./sizing/expiryDecay.ts"; export type { ReservationPriceConfig }; export type PricingStrategyName = "effective-spread" | "reservation-price"; @@ -57,6 +58,8 @@ export interface QuoterConfig { export class Quoter { private tick = 0n; private context: InstrumentContext = {}; + /** Multiplier on `sizing.baseQuantity` (1 = full). Used for futures expiry decay. */ + private sizeScale = 1; private readonly instrument: InstrumentAdapter; private readonly cfg: QuoterConfig; private readonly oracle: OracleTracker; @@ -83,6 +86,11 @@ export class Quoter { this.logger = logger.child({ component: "quoter", instrument: instrument.id }); } + /** Set the baseQuantity multiplier (e.g. expirySizeDecay^index for futures). */ + setSizeScale(scale: number): void { + this.sizeScale = Number.isFinite(scale) && scale > 0 ? scale : 0; + } + async initialize(): Promise { this.tick = await this.instrument.book.tick(); this.context = await this.instrument.getContext(); @@ -170,11 +178,12 @@ export class Quoter { private computeSizes(): bigint[] { const s = this.cfg.sizing; + const base = scaleBaseQuantity(s.baseQuantity, this.sizeScale); if (s.strategy === "linear") { - return linearSizes(s.baseQuantity, s.numLevelsPerSide); + return linearSizes(base, s.numLevelsPerSide); } return geometricTaperSizes( - s.baseQuantity * BigInt(s.numLevelsPerSide), + base * BigInt(s.numLevelsPerSide), s.taperRatio, s.numLevelsPerSide, ); diff --git a/market-maker/src/core/sizing/expiryDecay.ts b/market-maker/src/core/sizing/expiryDecay.ts new file mode 100644 index 0000000..fe36193 --- /dev/null +++ b/market-maker/src/core/sizing/expiryDecay.ts @@ -0,0 +1,37 @@ +/** + * Scale quote size by futures expiry rank (0 = nearest). + * + * scale(i) = expirySizeDecay^i + * + * Nearest keeps full size; each further expiry is `decay` × the previous. + * `decay = 1` disables the schedule. + */ + +import Fraction from "fraction.js"; +import { toBigint } from "../rational.ts"; + +/** Multiplier in [0, 1] for expiry index `i` under geometric decay. */ +export function expirySizeScale(expiryIndex: number, expirySizeDecay: number): number { + if (!Number.isFinite(expiryIndex) || expiryIndex <= 0) return 1; + if (!Number.isFinite(expirySizeDecay) || expirySizeDecay >= 1) return 1; + if (expirySizeDecay <= 0) return 0; + let scale = 1; + for (let i = 0; i < expiryIndex; i++) scale *= expirySizeDecay; + return scale; +} + +/** + * Apply a size scale to a venue-native base quantity. + * Floors at 1 when `baseQuantity > 0` so a far expiry still quotes something. + */ +export function scaleBaseQuantity(baseQuantity: bigint, scale: number): bigint { + if (baseQuantity <= 0n) return 0n; + if (!Number.isFinite(scale) || scale >= 1) return baseQuantity; + if (scale <= 0) return 1n; + const scaled = toBigint( + new Fraction(baseQuantity).mul(new Fraction(Math.round(scale * 1_000_000), 1_000_000)), + 1n, + "nearest", + ); + return scaled < 1n ? 1n : scaled; +} diff --git a/market-maker/tests/apps/perps/config.test.ts b/market-maker/tests/apps/perps/config.test.ts index e11aa74..a9ff320 100644 --- a/market-maker/tests/apps/perps/config.test.ts +++ b/market-maker/tests/apps/perps/config.test.ts @@ -29,9 +29,10 @@ pricing: inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "1000000" numLevelsPerSide: 5 + taperRatio: 0.6 risk: maxPositionSize: 50 maxUtilizationPct: 80 @@ -64,7 +65,8 @@ describe("loadPerpsConfig", () => { assert.strictEqual(cfg.venue.kind, "perps"); assert.strictEqual(cfg.network.name, "arbitrum"); assert.strictEqual(cfg.pricing.strategy, "effective-spread"); - assert.strictEqual(cfg.sizing.strategy, "linear"); + assert.strictEqual(cfg.sizing.strategy, "geometric-taper"); + assert.strictEqual(cfg.sizing.taperRatio, 0.6); }); it("rejects reservation-price strategy on perps", () => { @@ -75,10 +77,10 @@ describe("loadPerpsConfig", () => { assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); - it("rejects geometric-taper sizing on perps", () => { + it("rejects linear sizing on perps", () => { const yaml = VALID_YAML - .replace("strategy: linear", "strategy: geometric-taper") - .replace(" numLevelsPerSide: 5\n", " numLevelsPerSide: 4\n taperRatio: 0.6\n"); + .replace("strategy: geometric-taper", "strategy: linear") + .replace(" taperRatio: 0.6\n", ""); const path = writeTmp(tmpDir, "test.yml", yaml); assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); }); diff --git a/market-maker/tests/apps/perps/main.smoke.test.ts b/market-maker/tests/apps/perps/main.smoke.test.ts index 317b639..febd7c7 100644 --- a/market-maker/tests/apps/perps/main.smoke.test.ts +++ b/market-maker/tests/apps/perps/main.smoke.test.ts @@ -22,7 +22,8 @@ describe("perps app config smoke", () => { const cfg = loadPerpsConfig({ path, env }); assert.equal(cfg.venue.kind, "perps"); assert.equal(cfg.pricing.strategy, "effective-spread"); - assert.equal(cfg.sizing.strategy, "linear"); + assert.equal(cfg.sizing.strategy, "geometric-taper"); + assert.ok(cfg.sizing.taperRatio > 0 && cfg.sizing.taperRatio < 1); assert.ok(cfg.timing.levelSpacingTicks >= 1); }); } diff --git a/market-maker/tests/apps/portfolio/config.test.ts b/market-maker/tests/apps/portfolio/config.test.ts index cbf201a..b7c487b 100644 --- a/market-maker/tests/apps/portfolio/config.test.ts +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -30,9 +30,10 @@ venues: inventorySkewGamma: 0.5 maxSkewTicks: 20 sizing: - strategy: linear + strategy: geometric-taper baseQuantity: "500000000" numLevelsPerSide: 4 + taperRatio: 0.6 - kind: futures address: "0x2222222222222222222222222222222222222222" maxPositionSize: 5 @@ -51,6 +52,7 @@ venues: baseQuantity: "500000000" numLevelsPerSide: 4 taperRatio: 0.6 + expirySizeDecay: 0.6 risk: maxPositionSize: 50 maxUtilizationPct: 80 @@ -84,6 +86,15 @@ describe("loadPortfolioConfig", () => { const fut = cfg.venues[1] as ParsedFuturesVenue; assert.deepEqual(fut.marketSelection, { mode: "nearest", count: 3 }); assert.equal(fut.sizing.baseQuantity, 500_000_000n); + assert.equal(fut.sizing.expirySizeDecay, 0.6); + }); + + it("defaults futures expirySizeDecay to 0.6 when omitted", () => { + const yaml = VALID_YAML.replace(" expirySizeDecay: 0.6\n", ""); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadPortfolioConfig({ path }); + const fut = cfg.venues[1] as ParsedFuturesVenue; + assert.equal(fut.sizing.expirySizeDecay, 0.6); }); it("defaults futures marketSelection to nearest-1 when omitted", () => { @@ -138,15 +149,7 @@ describe("loadPortfolioConfig", () => { inventorySkewGamma: 0.5 maxSkewTicks: 20`, ) - .replace( - ` strategy: geometric-taper - baseQuantity: "500000000" - numLevelsPerSide: 4 - taperRatio: 0.6`, - ` strategy: linear - baseQuantity: "500000000" - numLevelsPerSide: 4`, - ); + .replace(" expirySizeDecay: 0.6\n", ""); const path = writeTmp(tmpDir, "test.yml", yaml); assert.throws(() => loadPortfolioConfig({ path }), /duplicate venue kind/); }); diff --git a/market-maker/tests/core/sizing/expiryDecay.test.ts b/market-maker/tests/core/sizing/expiryDecay.test.ts new file mode 100644 index 0000000..206913e --- /dev/null +++ b/market-maker/tests/core/sizing/expiryDecay.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { expirySizeScale, scaleBaseQuantity } from "../../../src/core/sizing/expiryDecay.ts"; + +describe("expirySizeScale", () => { + it("keeps full size at index 0", () => { + assert.equal(expirySizeScale(0, 0.6), 1); + }); + + it("decays geometrically by index", () => { + assert.ok(Math.abs(expirySizeScale(1, 0.6) - 0.6) < 1e-12); + assert.ok(Math.abs(expirySizeScale(2, 0.6) - 0.36) < 1e-12); + }); + + it("disables when decay >= 1", () => { + assert.equal(expirySizeScale(2, 1), 1); + }); + + it("returns 0 when decay <= 0", () => { + assert.equal(expirySizeScale(1, 0), 0); + }); +}); + +describe("scaleBaseQuantity", () => { + it("leaves base unchanged at scale 1", () => { + assert.equal(scaleBaseQuantity(100n, 1), 100n); + }); + + it("scales and rounds to nearest", () => { + assert.equal(scaleBaseQuantity(10n, 0.6), 6n); + }); + + it("floors at 1 when base > 0", () => { + assert.equal(scaleBaseQuantity(2n, 0.1), 1n); + }); + + it("keeps zero base at zero", () => { + assert.equal(scaleBaseQuantity(0n, 0.6), 0n); + }); +}); From 54b4b08fa268ab1a187da7b8322ac93fa971cccb Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 15:21:30 +0200 Subject: [PATCH 105/180] feat(mm): raise dev collateral cap and split human/raw health Increase portfolio.dev maxCollateralAmount to 5000 USDC for requote headroom, and expose wallet vs vault balances on /health as labeled strings with /health/raw keeping the prior machine-readable payload. Co-authored-by: Cursor --- market-maker/README.md | 13 ++- market-maker/configs/portfolio.dev.yml | 4 +- market-maker/src/core/config/units.ts | 15 +++ market-maker/src/core/healthFormat.ts | 48 ++++++++ market-maker/src/core/healthcheck.ts | 83 +++++++++++-- market-maker/src/core/portfolioHealth.ts | 116 ++++++++++++++++--- market-maker/tests/core/config/units.test.ts | 20 +++- market-maker/tests/core/healthFormat.test.ts | 40 +++++++ 8 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 market-maker/src/core/healthFormat.ts create mode 100644 market-maker/tests/core/healthFormat.test.ts diff --git a/market-maker/README.md b/market-maker/README.md index 436434f..74e1bbb 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -216,12 +216,13 @@ pnpm prd:futures ## Health endpoint -`GET http://localhost:{healthPort}/health` returns a JSON snapshot of -live operational state — status, halt/throttle reasons, oracle and -gas readings, position and collateral, order counts, and uptime. -Suitable for liveness/readiness probes and for scraping into a -dashboard. The exact field set is exercised by -`tests/core/healthcheck.test.ts`. +`GET http://localhost:{healthPort}/health` returns a human-readable +JSON snapshot (e.g. `"1500 USDC"`, `"44m 35s"`) with wallet vs vault +balances called out clearly. + +`GET http://localhost:{healthPort}/health/raw` returns the previous +machine-readable shape (base-unit decimal strings, full config) for +probes and scrapers. ## Testing diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index c493beb..69c5b66 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -104,7 +104,9 @@ timing: collateral: autoDeposit: true autoDepositMinAmount: 500 - maxCollateralAmount: 1500 + # Raised so autoDeposit can top up past the prior 1500 ceiling and leave + # headroom for requotes after fills (util was pinning at ~80%). + maxCollateralAmount: 5000 oracle: # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless diff --git a/market-maker/src/core/config/units.ts b/market-maker/src/core/config/units.ts index a1b7327..3273239 100644 --- a/market-maker/src/core/config/units.ts +++ b/market-maker/src/core/config/units.ts @@ -29,6 +29,21 @@ function toDecimalString(input: unknown, field: string): string { throw new ConfigError(`${field}: expected string or number, got ${typeof input}`); } +/** + * Format a USD/USDC base-unit bigint as a decimal string (no float). + * `50_000_000n` → `"50"`, `500_000n` → `"0.5"`, `1n` → `"0.000001"`. + */ +export function formatUsd(amount: bigint, decimals: number = 6): string { + const neg = amount < 0n; + const abs = neg ? -amount : amount; + const scale = 10n ** BigInt(decimals); + const whole = abs / scale; + const frac = abs % scale; + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + const body = fracStr.length > 0 ? `${whole}.${fracStr}` : `${whole}`; + return neg ? `-${body}` : body; +} + /** * Parse a USD-denominated decimal value into a bigint with the given * `decimals` (6 for USDC). "50" → 50_000_000n, "0.5" → 500_000n, "50.123456" diff --git a/market-maker/src/core/healthFormat.ts b/market-maker/src/core/healthFormat.ts new file mode 100644 index 0000000..3b1d8fb --- /dev/null +++ b/market-maker/src/core/healthFormat.ts @@ -0,0 +1,48 @@ +/** + * Human-readable formatting helpers for /health (ops-facing). + * Machine-readable raw base units stay on /health/raw. + */ + +import { formatUsd } from "./config/units.ts"; + +/** `1500000000n` → `"1500 USDC"`. */ +export function formatUsdcAmount(amount: bigint): string { + return `${formatUsd(amount)} USDC`; +} + +/** Wei → `"0.483 ETH"` (trim trailing zeros). */ +export function formatEthAmount(wei: bigint): string { + return `${formatUsd(wei, 18)} ETH`; +} + +/** Price in token decimals (usually 6) → `"32.7976"`. */ +export function formatPrice(price: bigint, decimals: number = 6): string { + return formatUsd(price, decimals); +} + +/** Seconds → `"44m 35s"`, `"1h 2m"`, `"3s"`. */ +export function formatDurationSec(totalSec: number): string { + if (!Number.isFinite(totalSec) || totalSec < 0) return "0s"; + const sec = Math.floor(totalSec); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const parts: string[] = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + return parts.join(" "); +} + +/** Epoch ms → ISO-8601, or `"never"` when unset. */ +export function formatTimestampMs(ms: number): string { + if (!ms || ms <= 0) return "never"; + return new Date(ms).toISOString(); +} + +/** Relative age from `nowMs`. */ +export function formatAgeMs(thenMs: number, nowMs: number = Date.now()): string { + if (!thenMs || thenMs <= 0) return "never"; + const ageSec = Math.max(0, Math.floor((nowMs - thenMs) / 1000)); + return `${formatDurationSec(ageSec)} ago`; +} diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index d83e957..db62ea1 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -10,6 +10,14 @@ import type { GasTracker } from "./gasTracker.ts"; import type { RiskManager } from "./riskManager.ts"; import type { ErrorInfo } from "./errors.ts"; import type { OwnOrder } from "./adapter.ts"; +import { + formatAgeMs, + formatDurationSec, + formatEthAmount, + formatPrice, + formatTimestampMs, + formatUsdcAmount, +} from "./healthFormat.ts"; export interface ExecutorStats { ordersPlaced: number; @@ -39,9 +47,10 @@ export interface HealthCheckOptions { /** * HTTP endpoint exposing health, status, and runtime config. * - * GET /health → JSON snapshot of all trackers and config (sanitised) - * POST /stop → pause the main loop, cancel resting orders (via onStop) - * POST /start → resume the main loop (via onStart) + * GET /health → human-readable strings ("1500 USDC", "44m 35s", …) + * GET /health/raw → machine-readable base units (previous /health shape) + * POST /stop → pause the main loop, cancel resting orders (via onStop) + * POST /start → resume the main loop (via onStart) */ export class HealthCheck { private server: Server | null = null; @@ -75,7 +84,9 @@ export class HealthCheck { if (req.method === "POST" && req.url === "/start") return this.handleStart(res); if (req.method === "GET" && req.url === "/health") - return this.handleHealth(res); + return this.handleHealthHuman(res); + if (req.method === "GET" && req.url === "/health/raw") + return this.handleHealthRaw(res); res.writeHead(404); res.end(); } catch (err) { @@ -89,8 +100,11 @@ export class HealthCheck { const port = this.opts.port; this.server.listen(port, () => { logger.info( - { url: `http://localhost:${port}/health` }, - "health endpoint started", + { + human: `http://localhost:${port}/health`, + raw: `http://localhost:${port}/health/raw`, + }, + "health endpoints started", ); resolve(); }); @@ -108,7 +122,62 @@ export class HealthCheck { }); } - private handleHealth(res: ServerResponse): void { + private handleHealthHuman(res: ServerResponse): void { + const { oracle, inventory, collateral, book, gas, risk } = this.opts; + const uptimeSec = Math.floor((Date.now() - this.startedAt) / 1000); + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptime: formatDurationSec(uptimeSec), + lastTickAt: formatTimestampMs(this.lastTickAt), + lastTickAge: formatAgeMs(this.lastTickAt), + market: { + oraclePrice: formatPrice(oracle.currentPrice), + bestBid: book.bestBid === 0n ? "none" : formatPrice(book.bestBid), + bestAsk: book.bestAsk === 0n ? "none" : formatPrice(book.bestAsk), + ownOrders: serializeOwnOrders(book.ownOrders), + }, + inventory: { + netPosition: inventory.netQuantity.toString(), + inventorySkew: fractionToNumber(inventory.inventorySkew), + }, + collateral: { + walletUsdc: formatUsdcAmount(collateral.walletTokenBalance), + vaultUsdc: formatUsdcAmount(collateral.vaultBalance), + portfolioImUsdc: formatUsdcAmount(collateral.portfolioIM), + portfolioMmUsdc: formatUsdcAmount(collateral.portfolioMM), + venueOrderMarginUsdc: formatUsdcAmount(collateral.venueOrderMargin), + venueUnrealizedPnlUsdc: formatUsdcAmount(collateral.venueUnrealizedPnl), + ethBalance: formatEthAmount(collateral.nativeBalance), + utilization: `${collateral.utilizationPct}%`, + }, + gas: { + gasPrice: `${(Number(gas.currentGasPrice) / 1e9).toFixed(4)} gwei`, + gasSpiking: gas.isGasSpiking, + gasSpike: `${fractionToNumber(gas.gasSpikePct).toFixed(0)}%`, + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsdc: formatUsdcAmount(risk.cumulativeGasCostUsd), + }, + stats: { + tickCount: this.tickCount, + ordersPlaced: this.executorStats?.ordersPlaced ?? 0, + ordersCancelled: this.executorStats?.ordersCancelled ?? 0, + reconcileCount: this.executorStats?.reconcileCount ?? 0, + }, + }, + bigIntReplacer, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); + } + + private handleHealthRaw(res: ServerResponse): void { const { oracle, inventory, collateral, book, gas, risk } = this.opts; const body = JSON.stringify( { diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts index ff1a7cf..56e00fc 100644 --- a/market-maker/src/core/portfolioHealth.ts +++ b/market-maker/src/core/portfolioHealth.ts @@ -7,6 +7,14 @@ import type { GasTracker } from "./gasTracker.ts"; import type { RiskManager } from "./riskManager.ts"; import type { MarketRuntime } from "./marketRuntime.ts"; import type { ErrorInfo } from "./errors.ts"; +import { + formatAgeMs, + formatDurationSec, + formatEthAmount, + formatPrice, + formatTimestampMs, + formatUsdcAmount, +} from "./healthFormat.ts"; export interface PortfolioHealthOptions { port: number; @@ -19,10 +27,11 @@ export interface PortfolioHealthOptions { } /** - * Portfolio-aware /health endpoint. Exposes one shared collateral/gas/risk - * block plus a per-market breakdown (circuit-breaker state, last error, - * position, top of book) so ops can see partial degradation rather than an - * all-or-nothing status. + * Portfolio-aware health server. + * + * GET /health → human-readable strings ("1500 USDC", "44m 35s", …) + * GET /health/raw → machine-readable base units (previous /health shape) + * POST /stop|/start → pause / resume the tick loop */ export class PortfolioHealthCheck { private server: Server | null = null; @@ -53,7 +62,8 @@ export class PortfolioHealthCheck { try { if (req.method === "POST" && req.url === "/stop") return this.handleStop(res); if (req.method === "POST" && req.url === "/start") return this.handleStart(res); - if (req.method === "GET" && req.url === "/health") return this.handleHealth(res); + if (req.method === "GET" && req.url === "/health") return this.handleHealthHuman(res); + if (req.method === "GET" && req.url === "/health/raw") return this.handleHealthRaw(res); res.writeHead(404); res.end(); } catch (err) { @@ -64,7 +74,13 @@ export class PortfolioHealthCheck { }); const { logger, port } = this.opts; this.server.listen(port, () => { - logger.info({ url: `http://localhost:${port}/health` }, "health endpoint started"); + logger.info( + { + human: `http://localhost:${port}/health`, + raw: `http://localhost:${port}/health/raw`, + }, + "health endpoints started", + ); resolve(); }); }); @@ -81,7 +97,64 @@ export class PortfolioHealthCheck { }); } - private handleHealth(res: ServerResponse): void { + /** Ops-facing: wallet vs vault USDC called out; amounts/durations as labeled strings. */ + private handleHealthHuman(res: ServerResponse): void { + const { collateral, gas, risk } = this.opts; + const uptimeSec = Math.floor((Date.now() - this.startedAt) / 1000); + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptime: formatDurationSec(uptimeSec), + lastTickAt: formatTimestampMs(this.lastTickAt), + lastTickAge: formatAgeMs(this.lastTickAt), + collateral: { + walletUsdc: formatUsdcAmount(collateral.walletTokenBalance), + vaultUsdc: formatUsdcAmount(collateral.vaultBalance), + portfolioImUsdc: formatUsdcAmount(collateral.portfolioIM), + portfolioMmUsdc: formatUsdcAmount(collateral.portfolioMM), + venueOrderMarginUsdc: formatUsdcAmount(collateral.venueOrderMargin), + venueUnrealizedPnlUsdc: formatUsdcAmount(collateral.venueUnrealizedPnl), + ethBalance: formatEthAmount(collateral.nativeBalance), + utilization: `${collateral.utilizationPct}%`, + }, + gas: { + gasPrice: `${(Number(gas.currentGasPrice) / 1e9).toFixed(4)} gwei`, + gasSpiking: gas.isGasSpiking, + gasSpike: `${fractionToNumber(gas.gasSpikePct).toFixed(0)}%`, + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsdc: formatUsdcAmount(risk.cumulativeGasCostUsd), + }, + markets: this.markets().map((m) => { + const s = m.healthState(); + return { + id: s.id, + breaker: s.breaker, + consecutiveErrors: s.consecutiveErrors, + lastError: s.lastError, + oraclePrice: formatPrice(BigInt(s.oraclePrice)), + bestBid: s.bestBid === "0" ? "none" : formatPrice(BigInt(s.bestBid)), + bestAsk: s.bestAsk === "0" ? "none" : formatPrice(BigInt(s.bestAsk)), + netPosition: s.netPosition, + ownOrders: s.ownOrders, + }; + }), + stats: { + tickCount: this.tickCount, + }, + }, + bigIntReplacer, + ); + this.respondJson(res, body); + } + + /** Machine-readable: previous /health payload (base units as decimal strings). */ + private handleHealthRaw(res: ServerResponse): void { const { collateral, gas, risk } = this.opts; const body = JSON.stringify( { @@ -116,16 +189,21 @@ export class PortfolioHealthCheck { }, bigIntReplacer, ); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(body); + this.respondJson(res, body); } private handleStop(res: ServerResponse): void { - if (this.paused) return this.respondOk(res); + if (this.paused) { + this.respondOk(res); + return; + } this.paused = true; this.status = "stopped"; this.lastError = null; - if (!this.onStop) return this.respondOk(res); + if (!this.onStop) { + this.respondOk(res); + return; + } this.onStop() .then(() => this.respondOk(res)) .catch((err) => { @@ -136,11 +214,17 @@ export class PortfolioHealthCheck { } private handleStart(res: ServerResponse): void { - if (!this.paused) return this.respondOk(res); + if (!this.paused) { + this.respondOk(res); + return; + } this.paused = false; this.status = "running"; this.lastError = null; - if (!this.onStart) return this.respondOk(res); + if (!this.onStart) { + this.respondOk(res); + return; + } this.onStart() .then(() => this.respondOk(res)) .catch((err) => { @@ -151,8 +235,12 @@ export class PortfolioHealthCheck { } private respondOk(res: ServerResponse): void { + this.respondJson(res, JSON.stringify({ ok: true, status: this.status })); + } + + private respondJson(res: ServerResponse, body: string): void { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, status: this.status })); + res.end(body); } } diff --git a/market-maker/tests/core/config/units.test.ts b/market-maker/tests/core/config/units.test.ts index 2d31b8f..550ac9e 100644 --- a/market-maker/tests/core/config/units.test.ts +++ b/market-maker/tests/core/config/units.test.ts @@ -1,6 +1,24 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseUsd, secondsToMs } from "../../../src/core/config/units.ts"; +import { formatUsd, parseUsd, secondsToMs } from "../../../src/core/config/units.ts"; + +describe("formatUsd", () => { + it("formats whole USDC amounts", () => { + assert.strictEqual(formatUsd(50_000_000n), "50"); + assert.strictEqual(formatUsd(0n), "0"); + }); + + it("formats fractional USDC without trailing zeros", () => { + assert.strictEqual(formatUsd(500_000n), "0.5"); + assert.strictEqual(formatUsd(1n), "0.000001"); + assert.strictEqual(formatUsd(123_456_789n), "123.456789"); + }); + + it("formats negative amounts", () => { + assert.strictEqual(formatUsd(-50_000_000n), "-50"); + assert.strictEqual(formatUsd(-500_000n), "-0.5"); + }); +}); describe("parseUsd", () => { it("converts integer USD to 6-decimal bigint", () => { diff --git a/market-maker/tests/core/healthFormat.test.ts b/market-maker/tests/core/healthFormat.test.ts new file mode 100644 index 0000000..f7d68a3 --- /dev/null +++ b/market-maker/tests/core/healthFormat.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + formatAgeMs, + formatDurationSec, + formatEthAmount, + formatPrice, + formatTimestampMs, + formatUsdcAmount, +} from "../../src/core/healthFormat.ts"; + +describe("healthFormat", () => { + it("formats USDC with unit suffix", () => { + assert.equal(formatUsdcAmount(1_500_000_000n), "1500 USDC"); + assert.equal(formatUsdcAmount(5_910_460_671n), "5910.460671 USDC"); + }); + + it("formats ETH from wei", () => { + assert.equal(formatEthAmount(10n ** 18n), "1 ETH"); + assert.equal(formatEthAmount(5n * 10n ** 17n), "0.5 ETH"); + }); + + it("formats prices", () => { + assert.equal(formatPrice(32_797_600n), "32.7976"); + }); + + it("formats durations", () => { + assert.equal(formatDurationSec(0), "0s"); + assert.equal(formatDurationSec(35), "35s"); + assert.equal(formatDurationSec(95), "1m 35s"); + assert.equal(formatDurationSec(3725), "1h 2m 5s"); + }); + + it("formats timestamps and ages", () => { + assert.equal(formatTimestampMs(0), "never"); + assert.equal(formatTimestampMs(1_000), "1970-01-01T00:00:01.000Z"); + assert.equal(formatAgeMs(0), "never"); + assert.equal(formatAgeMs(Date.now() - 5_000, Date.now()), "5s ago"); + }); +}); From 0ca704dacbd02f4c77dd2798d71d3efb0651cedb Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:14:27 +0200 Subject: [PATCH 106/180] chore: add biome lint gates and align Hardhat 3.9.1 Add local biome lint scripts across packages, wire lint/typecheck into CI, and bump contracts/points-indexer Hardhat to 3.9.1 so tooling matches futures and perps. Fix points fixture typing and biome errors blocking the new gates. Co-authored-by: Cursor --- .github/workflows/contract-tests.yml | 9 + .github/workflows/indexer-tests.yml | 3 + .github/workflows/keeper-test.yml | 4 + .github/workflows/market-maker-tests.yml | 3 + .github/workflows/points-indexer-tests.yml | 4 + biome.json | 1 + contracts/package.json | 10 +- contracts/pnpm-lock.yaml | 315 ++++++++++++------ contracts/scripts/deploy-points.ts | 12 +- contracts/tests/points.test.ts | 6 +- contracts/tests/pointsIntegrationFixtures.ts | 9 +- indexer/package.json | 7 +- indexer/pnpm-lock.yaml | 91 +++++ keeper/package.json | 5 +- keeper/pnpm-lock.yaml | 99 +++++- keeper/tests/predict/coordinator.test.ts | 2 - .../tests/predict/coordinatorAlerts.test.ts | 2 - keeper/tests/predict/snapshot.test.ts | 6 +- market-maker/package.json | 5 +- market-maker/pnpm-lock.yaml | 109 +++++- market-maker/src/core/portfolioHealth.ts | 20 +- points-indexer/package.json | 13 +- points-indexer/pnpm-lock.yaml | 231 +++++++++---- 23 files changed, 754 insertions(+), 212 deletions(-) diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index 6a15109..5915f6c 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -57,5 +57,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Compile contracts + run: pnpm compile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + - name: Run tests run: pnpm test diff --git a/.github/workflows/indexer-tests.yml b/.github/workflows/indexer-tests.yml index 130dc8b..85e245c 100644 --- a/.github/workflows/indexer-tests.yml +++ b/.github/workflows/indexer-tests.yml @@ -72,6 +72,9 @@ jobs: - name: Codegen run: pnpm codegen + - name: Lint + run: pnpm lint + - name: Build run: pnpm build diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 6ce7181..5279eb5 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -74,6 +74,10 @@ jobs: echo "futures-marketplace-abi layout ok:" ls -la "$pkg" | head -20 + - name: Lint + working-directory: ./keeper + run: pnpm lint + - name: TypeCheck working-directory: ./keeper run: pnpm typecheck diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml index 11317cb..0ac2149 100644 --- a/.github/workflows/market-maker-tests.yml +++ b/.github/workflows/market-maker-tests.yml @@ -57,6 +57,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/points-indexer-tests.yml b/.github/workflows/points-indexer-tests.yml index ee9b04e..fd6cb80 100644 --- a/.github/workflows/points-indexer-tests.yml +++ b/.github/workflows/points-indexer-tests.yml @@ -85,6 +85,10 @@ jobs: working-directory: ./points-indexer run: pnpm codegen + - name: Lint + working-directory: ./points-indexer + run: pnpm lint + - name: Build working-directory: ./points-indexer run: pnpm build diff --git a/biome.json b/biome.json index a933c07..305aaf5 100644 --- a/biome.json +++ b/biome.json @@ -25,6 +25,7 @@ "noForEach": "off" }, "correctness": { + "noConstantCondition": "off", "noUnusedImports": "warn" }, "style": { diff --git a/contracts/package.json b/contracts/package.json index 059d864..67b030a 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -9,6 +9,8 @@ "scripts": { "compile": "hardhat compile", "test": "hardhat test", + "typecheck": "tsgo --noEmit", + "lint": "biome lint .", "clean": "rm -rf abi artifacts cache", "deploy:vault": "hardhat run scripts/deploy-collateral-vault.ts", "deploy:pme": "hardhat run scripts/deploy-portfolio-margin-engine.ts", @@ -19,10 +21,12 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", - "@nomicfoundation/hardhat-toolbox-viem": "^5.0.0", - "@nomicfoundation/hardhat-verify": "^3.0.0", + "@nomicfoundation/hardhat-toolbox-viem": "^5.0.7", + "@nomicfoundation/hardhat-verify": "^3.0.17", + "@nomicfoundation/hardhat-viem": "3.0.9", "@types/node": "^22.0.0", - "hardhat": "^3.2", + "@typescript/native-preview": "7.0.0-dev.20260707.2", + "hardhat": "^3.9.1", "typescript": "^5.8.0" }, "dependencies": { diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 1ac727d..2dbe251 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -25,17 +25,23 @@ importers: specifier: ^2.4.10 version: 2.4.10 '@nomicfoundation/hardhat-toolbox-viem': - specifier: ^5.0.0 - version: 5.0.4(15ef5180f08ad93849a5892a76f3228f) + specifier: ^5.0.7 + version: 5.0.7(a7c1ad8adca5d73f1855378c51cc72e8) '@nomicfoundation/hardhat-verify': - specifier: ^3.0.0 - version: 3.0.15(hardhat@3.4.2) + specifier: ^3.0.17 + version: 3.0.17(hardhat@3.9.1) + '@nomicfoundation/hardhat-viem': + specifier: 3.0.9 + version: 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260707.2 + version: 7.0.0-dev.20260707.2 hardhat: - specifier: ^3.2 - version: 3.4.2 + specifier: ^3.9.1 + version: 3.9.1 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -376,41 +382,44 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@nomicfoundation/edr-darwin-arm64@0.12.0-next.29': - resolution: {integrity: sha512-qzVcAkUsrVT2Za9pLzTYL/eNLS09R+JSG+4LpQ56Wg3mkjbwItn/F6C/XbGqMbNiEGfLi5kVvtYOtT7yu04/Tg==} + '@nomicfoundation/edr-darwin-arm64@0.12.1': + resolution: {integrity: sha512-KRB7oRupR2CqGHTACDhdS/EJGLN2rft1+5UNeimbXYe9nS3usUNGjNJyIjvoxzFthqnFM3+vaDQwyIZfq/eRjw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-darwin-x64@0.12.0-next.29': - resolution: {integrity: sha512-P2BSYLsDoM1dGi/0NO3ps3l76NbvFDAnmCUS5SLLLdG/b8RUvWKHtfZFrQgy1KCPDFiiG5IlwzcmAwsPjsyOVQ==} + '@nomicfoundation/edr-darwin-x64@0.12.1': + resolution: {integrity: sha512-h6J3otsX5ib1md5V/M281ZS37FC6mAH8QlxVi3YMe9wOpEOpBRkqfhQAeFCekdx+5pqNHO/STi5OyKwCd4YAfw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-arm64-gnu@0.12.0-next.29': - resolution: {integrity: sha512-3SKZIaZCCuY6fAHj6GpTYpQPj3S0LFO6YUUZw3aSg1joBmM9FkP9a1IiYQOBZnZqk0Fa+pHy2dHMVWDczQHX7g==} + '@nomicfoundation/edr-linux-arm64-gnu@0.12.1': + resolution: {integrity: sha512-yqJcBgusn+MQFCemVrm7VIYjqQLaFouo0DBAbApE0GHQ7MnVFmbW2d2WCEln3jZOZgY0FH0tfnQw/NfK2xo2zg==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-arm64-musl@0.12.0-next.29': - resolution: {integrity: sha512-GGDJX3We8+XQ0L1Yy2i6ulbucQPO7HpQ5IYkxFLKL0H611ErErHLawrGIvfcfaDYfnFrC/3uxWEqMQ4GhrWbBQ==} + '@nomicfoundation/edr-linux-arm64-musl@0.12.1': + resolution: {integrity: sha512-JPkUOazqotQMvU2wsOQymxusCKyWaWdqHyxQKqwrqz81O+jOEXvMHUp20a6cRbVGOoGHx334ORj+daSGKvt5og==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-x64-gnu@0.12.0-next.29': - resolution: {integrity: sha512-d92L55iy/EzMlu341RgFG1Pqd6Mpd1MmcYi48Na2VtMs7rqRIcAUGeLgoooScLinM5JqTIb1uYVghehFrx98gA==} + '@nomicfoundation/edr-linux-x64-gnu@0.12.1': + resolution: {integrity: sha512-pM3cP316WgSUUy6MW2FuWgjZuonCYULED8Mn1mIK6NfwzTKooves/KjBDzIzr7Mvht9SwF/tT0KRjHPf/9E8gg==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-x64-musl@0.12.0-next.29': - resolution: {integrity: sha512-oOupaxyR6KUzvhJ0zsVU0yfeepB3hcTKxvowq2lPPwp6cMFzPY8PFe6uck+7+rXwog0dDkEa4R/RPEE9DtUelw==} + '@nomicfoundation/edr-linux-x64-musl@0.12.1': + resolution: {integrity: sha512-Rw7hhyk8PdZy3bBYVJrQX1M1AIBhy4vFnWPfbdY50c+ZfX/c82PYVg+B92+XaC5avMon/KiIhfB2fNLcyJy4uw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-win32-x64-msvc@0.12.0-next.29': - resolution: {integrity: sha512-QWvz4tTt1Z5JYKXODtqqdxfIQMiWFPGLVIeVJlXl2HSPJAIWTMU+EiBhlGGxP0qoUotUbdJbe7lpG4gw3yKIcA==} + '@nomicfoundation/edr-win32-x64-msvc@0.12.1': + resolution: {integrity: sha512-z2ILUf8P/oqG8t2tkPCpmhzSpo+LMZylLFUPGMgugHwe8OX1GyU11g0bQU8SoIwHozy7MLzTMX0NZ/14LWSN7Q==} engines: {node: '>= 20'} - '@nomicfoundation/edr@0.12.0-next.29': - resolution: {integrity: sha512-/c1LbBC3EFgOIKRup0lQ2SIqL9MLdqdOHDM9Mta5CyDOk9cQFlBSTpWCGDrh+p1BIsPFpVHqa4yjkBmZyL1aZA==} + '@nomicfoundation/edr@0.12.1': + resolution: {integrity: sha512-1U8C+kiVMIbVkOW+Sa7sUm9glSaB5cMe7UJ9wCOHFPpBBUQgStgrgAOWOahRL0vKRUjHUpuQpg47cRcUSdmW/A==} engines: {node: '>= 20'} '@nomicfoundation/hardhat-errors@3.0.11': resolution: {integrity: sha512-XEKplQ+FhZD1PgIGSj62scoqB/y+uG8x+V+U68m1a+4L1I46y4/gZQGIuMLkRZeqhPHsLle6ykDB+vn8qtwqzw==} + '@nomicfoundation/hardhat-errors@3.0.17': + resolution: {integrity: sha512-x8/Bv7Mn0a90ZRX4ZfWuq8uGuqF10LzLMXD1LD0kEIRBwSvr71fcxYyONcuf+MzznbrJ+WBTNz3ov9Rd++8DfQ==} + '@nomicfoundation/hardhat-ignition-viem@3.1.1': resolution: {integrity: sha512-Jq718d4kU8CNFm1MG8kmHAvLtxwXk4TiFV9cqbgjQnAf/ZuJ/xVFBMOZDRYFHvH/Yx8Bs0jxovcIfoy+4RVEag==} peerDependencies: @@ -445,8 +454,8 @@ packages: peerDependencies: hardhat: ^3.2.0 - '@nomicfoundation/hardhat-toolbox-viem@5.0.4': - resolution: {integrity: sha512-yXFcdpNx4/arbCnlt5QE3OsE7Bo+35NXGyRKRBgXt97cJBDZIZZYrFwIcYmmeo+1fYTTbggHXOptMd6Ev4xFAg==} + '@nomicfoundation/hardhat-toolbox-viem@5.0.7': + resolution: {integrity: sha512-aHF77tTYmBIbuxY99KKBPx4cLWdcQvvar1PpzWJyrDgC+P6rA7Edi1GpvvPcwO/HdfZXuACQ74zSS2PnmJu3mA==} peerDependencies: '@nomicfoundation/hardhat-ignition': ^3.0.7 '@nomicfoundation/hardhat-ignition-viem': ^3.0.7 @@ -457,17 +466,20 @@ packages: '@nomicfoundation/hardhat-viem': ^3.0.4 '@nomicfoundation/hardhat-viem-assertions': ^3.0.5 '@nomicfoundation/ignition-core': ^3.0.7 - hardhat: ^3.4.0 + hardhat: ^3.8.0 viem: ^2.47.6 '@nomicfoundation/hardhat-utils@4.0.5': resolution: {integrity: sha512-+M4gdNhj5zqElduDckzFza4oBXEwYaLdMeJuIqprDwb4iQkDxLVeKQgIWFp1SdRCqJ116QSDYFVZApznHdcoZg==} - '@nomicfoundation/hardhat-vendored@3.0.2': - resolution: {integrity: sha512-v65aSwA0k15QzMUL4cFXT2CPnrjrxR1BE2V24BjNCPnlhwI/2e1Gfy7TaIGSor5yZGeiQ5ScI+wP/ovKxQBr0g==} + '@nomicfoundation/hardhat-utils@4.1.5': + resolution: {integrity: sha512-EokhnMFDkDQPSsxrzyDAuCJuMbEsNWfMIMPgeB+FY8wnArl+dZR7tkd5UV2iooSlyxF+H3zj/0RKReXscTyijQ==} + + '@nomicfoundation/hardhat-vendored@3.0.4': + resolution: {integrity: sha512-RO8Otj1FvRvxJmXzkxh1vTwK/+cqSVPYLqY6RrWkmzHEEcxnAwAFsBYdW7xyTEyW/pVbSSNd2gs3aoGdGZaoNA==} - '@nomicfoundation/hardhat-verify@3.0.15': - resolution: {integrity: sha512-6DO8Z0MzyU4p89lPJVhsCX0CsOopkYhptatdt0uSfsqHdNz6JOtZ/KTTRuNXDI4QpR+KPq2GDkD4KqfAMX9WfQ==} + '@nomicfoundation/hardhat-verify@3.0.17': + resolution: {integrity: sha512-I9n/tvp0K9MoqbBee/SB69MNpd6O8KHtBNxlqMuRDYMMqaL2qxNjztwBh28JukusuwaYtGpqvJoioVAam79o9Q==} peerDependencies: hardhat: ^3.4.0 @@ -478,17 +490,22 @@ packages: hardhat: ^3.0.0 viem: ^2.43.0 - '@nomicfoundation/hardhat-viem@3.0.4': - resolution: {integrity: sha512-zauCnEMOgy8+nijwVOAQr7piGYsWtUFb0i2snaBF4e7TJWbcXbyJL3wGBplo3h0D9UOYYmLDE7DWTxuqZjEsZA==} + '@nomicfoundation/hardhat-viem@3.0.9': + resolution: {integrity: sha512-GtQ7l55C70Jj80yrZGdW7Kah0vPmEje29G/xVfcxchVxRRFdj1XyFZFn+7e53b11qhHMWDB787pVN+I8YZae3w==} peerDependencies: - hardhat: ^3.1.11 - viem: ^2.43.0 + hardhat: ^3.8.0 + viem: ^2.47.6 '@nomicfoundation/hardhat-zod-utils@3.0.4': resolution: {integrity: sha512-yCiycXDEEjbNgNVQaUoGYOee6+ljYUnIOWMtYc/dYDuwlHutWr9xg/KgkgMkiZZ1R2WrZAEqsSaeZTnH7Oyz9Q==} peerDependencies: zod: ^3.23.8 + '@nomicfoundation/hardhat-zod-utils@3.0.5': + resolution: {integrity: sha512-A1G9Jcizf/vYcGMtqkf+st94zBPTDB+bXXlojOMu77gmBZYbywY0k7hdRM2B4uJY+8nM0oe0sNVGVkARITXdcw==} + peerDependencies: + zod: ^3.23.8 + '@nomicfoundation/ignition-core@3.1.1': resolution: {integrity: sha512-GcVYniz1jlEogq61y6yCZm6AZJzASFfmDvYj3+bJRkASs14bfctvQ0gkO2bV3bMoxIijfAEJQlDmFwWjXjj/ow==} @@ -572,6 +589,53 @@ packages: '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} + engines: {node: '>=16.20.0'} + hasBin: true + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -693,8 +757,8 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - hardhat@3.4.2: - resolution: {integrity: sha512-6NUzfGFwHdaAvNmSwsZngH1vuUd8MWN92yTM4uo2VsrRGF5bsF1GWbZd7+UJO6/8zYOwViEbWYu7J4ypmSNr/g==} + hardhat@3.9.1: + resolution: {integrity: sha512-yg+0oH5tWqdsxITh6fAJjAWOSHOkC2VPlsJDJwoifs2QS1t7kyRciMy5O2F846qzH+4iqRn1rbv/5voykX3RSQ==} hasBin: true has-flag@4.0.0: @@ -1223,29 +1287,29 @@ snapshots: '@noble/hashes@1.8.0': {} - '@nomicfoundation/edr-darwin-arm64@0.12.0-next.29': {} + '@nomicfoundation/edr-darwin-arm64@0.12.1': {} - '@nomicfoundation/edr-darwin-x64@0.12.0-next.29': {} + '@nomicfoundation/edr-darwin-x64@0.12.1': {} - '@nomicfoundation/edr-linux-arm64-gnu@0.12.0-next.29': {} + '@nomicfoundation/edr-linux-arm64-gnu@0.12.1': {} - '@nomicfoundation/edr-linux-arm64-musl@0.12.0-next.29': {} + '@nomicfoundation/edr-linux-arm64-musl@0.12.1': {} - '@nomicfoundation/edr-linux-x64-gnu@0.12.0-next.29': {} + '@nomicfoundation/edr-linux-x64-gnu@0.12.1': {} - '@nomicfoundation/edr-linux-x64-musl@0.12.0-next.29': {} + '@nomicfoundation/edr-linux-x64-musl@0.12.1': {} - '@nomicfoundation/edr-win32-x64-msvc@0.12.0-next.29': {} + '@nomicfoundation/edr-win32-x64-msvc@0.12.1': {} - '@nomicfoundation/edr@0.12.0-next.29': + '@nomicfoundation/edr@0.12.1': dependencies: - '@nomicfoundation/edr-darwin-arm64': 0.12.0-next.29 - '@nomicfoundation/edr-darwin-x64': 0.12.0-next.29 - '@nomicfoundation/edr-linux-arm64-gnu': 0.12.0-next.29 - '@nomicfoundation/edr-linux-arm64-musl': 0.12.0-next.29 - '@nomicfoundation/edr-linux-x64-gnu': 0.12.0-next.29 - '@nomicfoundation/edr-linux-x64-musl': 0.12.0-next.29 - '@nomicfoundation/edr-win32-x64-msvc': 0.12.0-next.29 + '@nomicfoundation/edr-darwin-arm64': 0.12.1 + '@nomicfoundation/edr-darwin-x64': 0.12.1 + '@nomicfoundation/edr-linux-arm64-gnu': 0.12.1 + '@nomicfoundation/edr-linux-arm64-musl': 0.12.1 + '@nomicfoundation/edr-linux-x64-gnu': 0.12.1 + '@nomicfoundation/edr-linux-x64-musl': 0.12.1 + '@nomicfoundation/edr-win32-x64-msvc': 0.12.1 '@nomicfoundation/hardhat-errors@3.0.11': dependencies: @@ -1253,28 +1317,32 @@ snapshots: transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-ignition-viem@3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(hardhat@3.4.2))(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(@nomicfoundation/hardhat-viem@3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-errors@3.0.17': + dependencies: + '@nomicfoundation/hardhat-utils': 4.1.5 + + '@nomicfoundation/hardhat-ignition-viem@3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': dependencies: '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(hardhat@3.4.2) - '@nomicfoundation/hardhat-verify': 3.0.15(hardhat@3.4.2) - '@nomicfoundation/hardhat-viem': 3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1) + '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) '@nomicfoundation/ignition-core': 3.1.1 - hardhat: 3.4.2 + hardhat: 3.9.1 viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(hardhat@3.4.2)': + '@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1)': dependencies: '@nomicfoundation/hardhat-errors': 3.0.11 '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-verify': 3.0.15(hardhat@3.4.2) + '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) '@nomicfoundation/ignition-core': 3.1.1 '@nomicfoundation/ignition-ui': 3.1.1 chalk: 5.6.2 debug: 4.4.3 - hardhat: 3.4.2 + hardhat: 3.9.1 json5: 2.2.3 prompts: 2.4.2 transitivePeerDependencies: @@ -1282,7 +1350,7 @@ snapshots: - supports-color - utf-8-validate - '@nomicfoundation/hardhat-keystore@3.0.5(hardhat@3.4.2)': + '@nomicfoundation/hardhat-keystore@3.0.5(hardhat@3.9.1)': dependencies: '@noble/ciphers': 1.2.1 '@noble/hashes': 1.7.1 @@ -1291,16 +1359,16 @@ snapshots: '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) chalk: 5.6.2 debug: 4.4.3 - hardhat: 3.4.2 + hardhat: 3.9.1 zod: 3.25.76 transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-network-helpers@3.0.4(hardhat@3.4.2)': + '@nomicfoundation/hardhat-network-helpers@3.0.4(hardhat@3.9.1)': dependencies: '@nomicfoundation/hardhat-errors': 3.0.11 '@nomicfoundation/hardhat-utils': 4.0.5 - hardhat: 3.4.2 + hardhat: 3.9.1 transitivePeerDependencies: - supports-color @@ -1310,30 +1378,30 @@ snapshots: chalk: 5.6.2 jest-diff: 29.7.0 - '@nomicfoundation/hardhat-node-test-runner@3.0.12(hardhat@3.4.2)': + '@nomicfoundation/hardhat-node-test-runner@3.0.12(hardhat@3.9.1)': dependencies: '@nomicfoundation/hardhat-errors': 3.0.11 '@nomicfoundation/hardhat-node-test-reporter': 3.0.3 '@nomicfoundation/hardhat-utils': 4.0.5 '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) - hardhat: 3.4.2 + hardhat: 3.9.1 tsx: 4.21.0 zod: 3.25.76 transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-toolbox-viem@5.0.4(15ef5180f08ad93849a5892a76f3228f)': + '@nomicfoundation/hardhat-toolbox-viem@5.0.7(a7c1ad8adca5d73f1855378c51cc72e8)': dependencies: - '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(hardhat@3.4.2) - '@nomicfoundation/hardhat-ignition-viem': 3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(hardhat@3.4.2))(@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2))(@nomicfoundation/hardhat-viem@3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) - '@nomicfoundation/hardhat-keystore': 3.0.5(hardhat@3.4.2) - '@nomicfoundation/hardhat-network-helpers': 3.0.4(hardhat@3.4.2) - '@nomicfoundation/hardhat-node-test-runner': 3.0.12(hardhat@3.4.2) - '@nomicfoundation/hardhat-verify': 3.0.15(hardhat@3.4.2) - '@nomicfoundation/hardhat-viem': 3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) - '@nomicfoundation/hardhat-viem-assertions': 3.0.7(@nomicfoundation/hardhat-viem@3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1) + '@nomicfoundation/hardhat-ignition-viem': 3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-keystore': 3.0.5(hardhat@3.9.1) + '@nomicfoundation/hardhat-network-helpers': 3.0.4(hardhat@3.9.1) + '@nomicfoundation/hardhat-node-test-runner': 3.0.12(hardhat@3.9.1) + '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-viem-assertions': 3.0.7(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) '@nomicfoundation/ignition-core': 3.1.1 - hardhat: 3.4.2 + hardhat: 3.9.1 viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-utils@4.0.5': @@ -1349,49 +1417,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-vendored@3.0.2': {} + '@nomicfoundation/hardhat-utils@4.1.5': + dependencies: + '@streamparser/json-node': 0.0.22 + env-paths: 2.2.1 + ethereum-cryptography: 2.2.1 + fast-equals: 5.4.0 + json-stream-stringify: 3.1.6 + rfdc: 1.4.1 + undici: 6.24.1 + + '@nomicfoundation/hardhat-vendored@3.0.4': {} - '@nomicfoundation/hardhat-verify@3.0.15(hardhat@3.4.2)': + '@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1)': dependencies: '@ethersproject/abi': 5.8.0 - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) cbor2: 1.12.0 - chalk: 5.6.2 - debug: 4.4.3 - hardhat: 3.4.2 - semver: 7.7.4 + hardhat: 3.9.1 zod: 3.25.76 - transitivePeerDependencies: - - supports-color - '@nomicfoundation/hardhat-viem-assertions@3.0.7(@nomicfoundation/hardhat-viem@3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-viem-assertions@3.0.7(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': dependencies: '@nomicfoundation/hardhat-errors': 3.0.11 '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-viem': 3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) - hardhat: 3.4.2 + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + hardhat: 3.9.1 viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) transitivePeerDependencies: - supports-color - '@nomicfoundation/hardhat-viem@3.0.4(hardhat@3.4.2)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 - hardhat: 3.4.2 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + hardhat: 3.9.1 viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) - transitivePeerDependencies: - - supports-color '@nomicfoundation/hardhat-zod-utils@3.0.4(zod@3.25.76)': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + zod: 3.25.76 + + '@nomicfoundation/hardhat-zod-utils@3.0.5(zod@3.25.76)': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 zod: 3.25.76 - transitivePeerDependencies: - - supports-color '@nomicfoundation/ignition-core@3.1.1': dependencies: @@ -1493,6 +1568,37 @@ snapshots: dependencies: undici-types: 6.19.8 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260707.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): optionalDependencies: typescript: 5.9.3 @@ -1622,19 +1728,17 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - hardhat@3.4.2: + hardhat@3.9.1: dependencies: - '@nomicfoundation/edr': 0.12.0-next.29 - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-vendored': 3.0.2 - '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) + '@nomicfoundation/edr': 0.12.1 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + '@nomicfoundation/hardhat-vendored': 3.0.4 + '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) '@nomicfoundation/solidity-analyzer': 0.1.2 '@sentry/core': 9.47.1 adm-zip: 0.4.16 - chalk: 5.6.2 chokidar: 4.0.3 - debug: 4.4.3 enquirer: 2.4.1 ethereum-cryptography: 2.2.1 micro-eth-signer: 0.14.0 @@ -1646,7 +1750,6 @@ snapshots: zod: 3.25.76 transitivePeerDependencies: - bufferutil - - supports-color - utf-8-validate has-flag@4.0.0: {} diff --git a/contracts/scripts/deploy-points.ts b/contracts/scripts/deploy-points.ts index 2aa320b..42940d3 100644 --- a/contracts/scripts/deploy-points.ts +++ b/contracts/scripts/deploy-points.ts @@ -128,13 +128,13 @@ async function main() { safe: SAFE_OWNER_ADDRESS, }); await logPrompt("Proceed?"); - let sim = await points.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); - let receipt = await writeAndWait(deployer, sim); - logStep("granted admin to Safe", txUrl(pc, receipt.transactionHash)); + const pointsAdminSim = await points.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); + const pointsAdminReceipt = await writeAndWait(deployer, pointsAdminSim); + logStep("granted admin to Safe", txUrl(pc, pointsAdminReceipt.transactionHash)); - sim = await hook.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); - receipt = await writeAndWait(deployer, sim); - logStep("granted hook admin to Safe", txUrl(pc, receipt.transactionHash)); + const hookAdminSim = await hook.simulate.grantRole([ADMIN_ROLE, SAFE_OWNER_ADDRESS]); + const hookAdminReceipt = await writeAndWait(deployer, hookAdminSim); + logStep("granted hook admin to Safe", txUrl(pc, hookAdminReceipt.transactionHash)); } // ── Summary ───────────────────────────────────────────────────────────────── diff --git a/contracts/tests/points.test.ts b/contracts/tests/points.test.ts index b3a1123..40cf279 100644 --- a/contracts/tests/points.test.ts +++ b/contracts/tests/points.test.ts @@ -61,7 +61,7 @@ describe("Points", () => { const fx = await networkHelpers.loadFixture(deployPointsFixture); await mintTo(fx, fx.alice.account.address, ONE_POINT); await viem.assertions.revertWithCustomError( - fx.points.write.transfer([fx.bob.account.address, ONE_POINT], { account: fx.alice.account }), + fx.points.read.transfer([fx.bob.account.address, ONE_POINT], { account: fx.alice.account }), fx.points, "TransfersDisabled", ); @@ -71,7 +71,7 @@ describe("Points", () => { const fx = await networkHelpers.loadFixture(deployPointsFixture); await mintTo(fx, fx.alice.account.address, ONE_POINT); await viem.assertions.revertWithCustomError( - fx.points.write.transferFrom([fx.alice.account.address, fx.bob.account.address, ONE_POINT], { + fx.points.read.transferFrom([fx.alice.account.address, fx.bob.account.address, ONE_POINT], { account: fx.owner.account, }), fx.points, @@ -82,7 +82,7 @@ describe("Points", () => { it("blocks approve and reports zero allowance", async () => { const { points, alice, bob } = await networkHelpers.loadFixture(deployPointsFixture); await viem.assertions.revertWithCustomError( - points.write.approve([bob.account.address, ONE_POINT], { account: alice.account }), + points.read.approve([bob.account.address, ONE_POINT], { account: alice.account }), points, "TransfersDisabled", ); diff --git a/contracts/tests/pointsIntegrationFixtures.ts b/contracts/tests/pointsIntegrationFixtures.ts index 7940eb2..208a0f4 100644 --- a/contracts/tests/pointsIntegrationFixtures.ts +++ b/contracts/tests/pointsIntegrationFixtures.ts @@ -115,9 +115,12 @@ export async function deployPointsStackFixture(conn: NetworkConnection) { const MINTER_ROLE = await points.read.MINTER_ROLE(); const BURNER_ROLE = await points.read.BURNER_ROLE(); const HOOK_CALLER_ROLE = await hook.read.HOOK_CALLER_ROLE(); - await points.write.grantRole([MINTER_ROLE, hook.address], { account: owner.account }); - await points.write.grantRole([BURNER_ROLE, redeemer.address], { account: owner.account }); - await hook.write.grantRole([HOOK_CALLER_ROLE, venue.account.address], { account: owner.account }); + await points.write.grantRole([MINTER_ROLE, hook.address], { account: owner.account, chain: null }); + await points.write.grantRole([BURNER_ROLE, redeemer.address], { account: owner.account, chain: null }); + await hook.write.grantRole([HOOK_CALLER_ROLE, venue.account.address], { + account: owner.account, + chain: null, + }); return { contracts: { points, hook, gov, escrow, redeemer }, diff --git a/indexer/package.json b/indexer/package.json index 1972e8e..ca5b5da 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -17,13 +17,16 @@ "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 collateral-vault", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", "test": "graph test -v 0.6.0", + "lint": "biome lint .", "indexer": "docker compose --env-file ../.env up", - "graph:api": "open http://localhost:8030/graphql/playground" + "graph:api": "open http://localhost:8030/graphql/playground", + "lint:fix": "biome check --write ." }, "dependencies": { "@graphprotocol/graph-ts": "0.38.2" }, "devDependencies": { + "@biomejs/biome": "2.4.13", "@graphprotocol/graph-cli": "^0.98.1", "@types/node": "^25.3.0", "assemblyscript": "^0.19.23", @@ -31,4 +34,4 @@ "typescript": "^5.9.3" }, "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" -} \ No newline at end of file +} diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml index 63cc38c..aada280 100644 --- a/indexer/pnpm-lock.yaml +++ b/indexer/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: 0.38.2 version: 0.38.2 devDependencies: + '@biomejs/biome': + specifier: 2.4.13 + version: 2.4.13 '@graphprotocol/graph-cli': specifier: ^0.98.1 version: 0.98.1(@types/node@25.6.0)(typescript@5.9.3)(zod@3.25.76) @@ -38,6 +41,59 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@chainsafe/is-ip@2.1.0': resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} @@ -1744,6 +1800,41 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + '@chainsafe/is-ip@2.1.0': {} '@chainsafe/netmask@2.0.0': diff --git a/keeper/package.json b/keeper/package.json index 306a216..88bf294 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -16,7 +16,9 @@ "pretest:integration": "pnpm node ./scripts/compile-siblings.ts", "test:integration": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/integration/**/*.test.ts'", "typecheck": "tsgo --noEmit", - "docker": "docker build -t collateral-margin-keeper ." + "lint": "biome lint .", + "docker": "docker build -t collateral-margin-keeper .", + "lint:fix": "biome check --write ." }, "dependencies": { "amaro": "^1.1.9", @@ -27,6 +29,7 @@ "viem": "^2.48.8" }, "devDependencies": { + "@biomejs/biome": "2.4.13", "@types/node": "^22.0.0", "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 6335b75..15ca5d5 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: specifier: ^2.48.8 version: 2.54.6 devDependencies: + '@biomejs/biome': + specifier: 2.4.13 + version: 2.4.13 '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -46,6 +49,63 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -150,7 +210,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} + resolution: {gitHosted: true, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} version: 0.0.0 end-of-stream@1.4.5: @@ -166,7 +226,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} + resolution: {gitHosted: true, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} version: 0.0.0 help-me@5.0.0: @@ -281,6 +341,41 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 3f92ca4..9b9c059 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -125,8 +125,6 @@ function buildHarness({ return 0n; case "getPendingFunding": return 0n; - case "getOrderMargin": - return 0n; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 8f282e1..a927270 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -100,8 +100,6 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return 0n; case "getPendingFunding": return 0n; - case "getOrderMargin": - return 0n; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index cd72ebd..b28d630 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -62,11 +62,11 @@ function makeChain(scripted: { }; } case "getOrderMargin": - return scripted.perpOrderMargin ?? 0n; + return c.address === PERPS + ? scripted.perpOrderMargin ?? 0n + : scripted.futuresOrderMargin ?? 0n; case "getPendingFunding": return scripted.perpFunding ?? 0n; - case "getOrderMargin": - return scripted.futuresOrderMargin ?? 0n; case "getActiveExpirationDates": return scripted.activeExpirationAts ?? []; case "imSpotShock": diff --git a/market-maker/package.json b/market-maker/package.json index 73fdc79..8ccf22b 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -13,6 +13,7 @@ "gen:schemas": "pnpm node scripts/gen-schemas.ts", "pretypecheck": "pnpm gen:schemas", "typecheck": "tsgo --noEmit", + "lint": "biome lint .", "node": "node --import=amaro/strip --conditions=typescript", "perps": "pnpm node --watch src/apps/perps/main.ts", "futures": "pnpm node --watch src/apps/futures/main.ts", @@ -28,7 +29,8 @@ "stg:portfolio": "pnpm portfolio --config configs/portfolio.stg.yml", "prd:perps": "pnpm perps --config configs/perps.prd.yml", "prd:futures": "pnpm futures --config configs/futures.prd.yml", - "prd:portfolio": "pnpm portfolio --config configs/portfolio.prd.yml" + "prd:portfolio": "pnpm portfolio --config configs/portfolio.prd.yml", + "lint:fix": "biome check --write ." }, "dependencies": { "@sinclair/typebox": "^0.34.49", @@ -44,6 +46,7 @@ "viem": "^2.45.3" }, "devDependencies": { + "@biomejs/biome": "2.4.13", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "@typescript/native-preview": "7.0.0-dev.20260525.1", diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 442f502..2583ed0 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: specifier: ^2.45.3 version: 2.48.4(typescript@5.9.3) devDependencies: + '@biomejs/biome': + specifier: 2.4.13 + version: 2.4.13 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -258,6 +261,59 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@bytecodealliance/preview2-shim@0.17.0': resolution: {integrity: sha512-JorcEwe4ud0x5BS/Ar2aQWOQoFzjq/7jcnxYXCvSMh0oRm0dQXzOA+hqLDBnOMks1LLBA7dmiLLsEBl09Yd6iQ==} @@ -453,6 +509,10 @@ 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} @@ -1913,10 +1973,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2972,6 +3028,41 @@ snapshots: '@babel/runtime@7.29.2': {} + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + '@bytecodealliance/preview2-shim@0.17.0': {} '@chainlink/contracts@1.5.0(@types/node@22.19.17)(ethers@5.8.0)': @@ -3481,6 +3572,8 @@ 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': @@ -4570,13 +4663,13 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/multicall3': https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@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/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4861,7 +4954,7 @@ snapshots: '@safe-global/types-kit': 2.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 viem: 2.48.4(typescript@5.9.3) transitivePeerDependencies: @@ -5280,8 +5373,6 @@ 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: {} diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts index ff1a7cf..1ec14b7 100644 --- a/market-maker/src/core/portfolioHealth.ts +++ b/market-maker/src/core/portfolioHealth.ts @@ -121,11 +121,17 @@ export class PortfolioHealthCheck { } private handleStop(res: ServerResponse): void { - if (this.paused) return this.respondOk(res); + if (this.paused) { + this.respondOk(res); + return; + } this.paused = true; this.status = "stopped"; this.lastError = null; - if (!this.onStop) return this.respondOk(res); + if (!this.onStop) { + this.respondOk(res); + return; + } this.onStop() .then(() => this.respondOk(res)) .catch((err) => { @@ -136,11 +142,17 @@ export class PortfolioHealthCheck { } private handleStart(res: ServerResponse): void { - if (!this.paused) return this.respondOk(res); + if (!this.paused) { + this.respondOk(res); + return; + } this.paused = false; this.status = "running"; this.lastError = null; - if (!this.onStart) return this.respondOk(res); + if (!this.onStart) { + this.respondOk(res); + return; + } this.onStart() .then(() => this.respondOk(res)) .catch((err) => { diff --git a/points-indexer/package.json b/points-indexer/package.json index 18f3d1a..15ba35e 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -19,21 +19,24 @@ "test": "graph test -v 0.6.0", "test:integration": "hardhat test nodejs", "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", + "lint": "biome lint .", "indexer": "docker compose --env-file ../.env up", - "graph:api": "open http://localhost:8030/graphql/playground" + "graph:api": "open http://localhost:8030/graphql/playground", + "lint:fix": "biome check --write ." }, "dependencies": { "@graphprotocol/graph-ts": "0.38.2" }, "devDependencies": { + "@biomejs/biome": "2.4.13", "@graphprotocol/graph-cli": "^0.98.1", - "@nomicfoundation/hardhat-network-helpers": "^3.0.8", - "@nomicfoundation/hardhat-node-test-runner": "^3.0.8", - "@nomicfoundation/hardhat-viem": "^3.0.7", + "@nomicfoundation/hardhat-network-helpers": "^3.0.11", + "@nomicfoundation/hardhat-node-test-runner": "3.0.17", + "@nomicfoundation/hardhat-viem": "3.0.9", "@types/node": "^25.3.0", "assemblyscript": "^0.19.23", "collateral-margin-contracts": "link:../contracts", - "hardhat": "^3.4.0", + "hardhat": "^3.9.1", "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/hardhat-matchstick-ts", "matchstick-as": "0.6.0", "matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/matchstick-ts", diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml index 3d24913..11df585 100644 --- a/points-indexer/pnpm-lock.yaml +++ b/points-indexer/pnpm-lock.yaml @@ -12,18 +12,21 @@ importers: specifier: 0.38.2 version: 0.38.2 devDependencies: + '@biomejs/biome': + specifier: 2.4.13 + version: 2.4.13 '@graphprotocol/graph-cli': specifier: ^0.98.1 version: 0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-network-helpers': - specifier: ^3.0.8 - version: 3.0.10(hardhat@3.9.0) + specifier: ^3.0.11 + version: 3.0.11(hardhat@3.9.1) '@nomicfoundation/hardhat-node-test-runner': - specifier: ^3.0.8 - version: 3.0.17(hardhat@3.9.0) + specifier: 3.0.17 + version: 3.0.17(hardhat@3.9.1) '@nomicfoundation/hardhat-viem': - specifier: ^3.0.7 - version: 3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + specifier: 3.0.9 + version: 3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@types/node': specifier: ^25.3.0 version: 25.9.2 @@ -34,11 +37,11 @@ importers: specifier: link:../contracts version: link:../contracts hardhat: - specifier: ^3.4.0 - version: 3.9.0 + 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.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.0) + 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) matchstick-as: specifier: 0.6.0 version: 0.6.0 @@ -77,6 +80,59 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@chainsafe/is-ip@2.1.0': resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} @@ -491,43 +547,46 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nomicfoundation/edr-darwin-arm64@0.12.0': - resolution: {integrity: sha512-z/8jU2dgZjhY2iLtJ1DGi3t/N2xbmjgok9K3R0f7+UZxSSJ5LbXCFn5So33fVh47RzGzOqEB+Yk4SdyUq2odqw==} + '@nomicfoundation/edr-darwin-arm64@0.12.1': + resolution: {integrity: sha512-KRB7oRupR2CqGHTACDhdS/EJGLN2rft1+5UNeimbXYe9nS3usUNGjNJyIjvoxzFthqnFM3+vaDQwyIZfq/eRjw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-darwin-x64@0.12.0': - resolution: {integrity: sha512-F9RrA60mEtxfKFiGB7QsydoUwzz4ckoustVMFegcIKmjRxRVb1qrRYiAc9oQiKMdJWIZKDYsOpHABJ9Um4U/+g==} + '@nomicfoundation/edr-darwin-x64@0.12.1': + resolution: {integrity: sha512-h6J3otsX5ib1md5V/M281ZS37FC6mAH8QlxVi3YMe9wOpEOpBRkqfhQAeFCekdx+5pqNHO/STi5OyKwCd4YAfw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-arm64-gnu@0.12.0': - resolution: {integrity: sha512-EtGRZbh1d4BF/1SIG5rVrKQ9R0nuNvPgCYiU5fCmY3bojAFOUf4m7I2ezIhim1vb1QBlXmFaoFNPZFIdMltBGA==} + '@nomicfoundation/edr-linux-arm64-gnu@0.12.1': + resolution: {integrity: sha512-yqJcBgusn+MQFCemVrm7VIYjqQLaFouo0DBAbApE0GHQ7MnVFmbW2d2WCEln3jZOZgY0FH0tfnQw/NfK2xo2zg==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-arm64-musl@0.12.0': - resolution: {integrity: sha512-5gWtmKuVfftcO1OUbF/3KTPVSR6klC7RI9Z96G5lDO325jQhYqOG+hkvDPKtM+nbYf+A0veOndghqbUgAX+E4A==} + '@nomicfoundation/edr-linux-arm64-musl@0.12.1': + resolution: {integrity: sha512-JPkUOazqotQMvU2wsOQymxusCKyWaWdqHyxQKqwrqz81O+jOEXvMHUp20a6cRbVGOoGHx334ORj+daSGKvt5og==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-x64-gnu@0.12.0': - resolution: {integrity: sha512-0Ty0fov3/NFL0dIshNTGCi06sjVzsgB7k+n9LAoj+57OsqP9X4e3P5XwjlTSNuyYshv8JdYMHqY+1ZIZ8SHsyQ==} + '@nomicfoundation/edr-linux-x64-gnu@0.12.1': + resolution: {integrity: sha512-pM3cP316WgSUUy6MW2FuWgjZuonCYULED8Mn1mIK6NfwzTKooves/KjBDzIzr7Mvht9SwF/tT0KRjHPf/9E8gg==} engines: {node: '>= 20'} - '@nomicfoundation/edr-linux-x64-musl@0.12.0': - resolution: {integrity: sha512-e5du3t17vdB3tAY0kl3Ip1cu8CcwiaeJeUC4mMPmL91HDM//AQehqpyIP8upam4AeIl1H6FA3xIUIDA6VOZSxg==} + '@nomicfoundation/edr-linux-x64-musl@0.12.1': + resolution: {integrity: sha512-Rw7hhyk8PdZy3bBYVJrQX1M1AIBhy4vFnWPfbdY50c+ZfX/c82PYVg+B92+XaC5avMon/KiIhfB2fNLcyJy4uw==} engines: {node: '>= 20'} - '@nomicfoundation/edr-win32-x64-msvc@0.12.0': - resolution: {integrity: sha512-wD8YxhFdlY2IXb7uVrSF7VHartFKXEYeiE6ISJOV10Y7YOUE1IzfwB0QOdKtLUepwB+HXis+KRy4h22dWEbT0A==} + '@nomicfoundation/edr-win32-x64-msvc@0.12.1': + resolution: {integrity: sha512-z2ILUf8P/oqG8t2tkPCpmhzSpo+LMZylLFUPGMgugHwe8OX1GyU11g0bQU8SoIwHozy7MLzTMX0NZ/14LWSN7Q==} engines: {node: '>= 20'} - '@nomicfoundation/edr@0.12.0': - resolution: {integrity: sha512-dVfrB70L//W05s+s+/c6n52Fct+kVKoXYT+/CKL9ZsNdq/yLr5LaPNsvpVkQHP1JdAiOrubUzA/MwZIk4gRxAQ==} + '@nomicfoundation/edr@0.12.1': + resolution: {integrity: sha512-1U8C+kiVMIbVkOW+Sa7sUm9glSaB5cMe7UJ9wCOHFPpBBUQgStgrgAOWOahRL0vKRUjHUpuQpg47cRcUSdmW/A==} engines: {node: '>= 20'} '@nomicfoundation/hardhat-errors@3.0.15': resolution: {integrity: sha512-h3r32RzpmWEcB2bz6aqZKlKOP8tzyvHLkPFleFFqwVvjO5AUfSoMUxm8OjaTuNgPF35mXPYRISh+kNCwmsKVOA==} - '@nomicfoundation/hardhat-network-helpers@3.0.10': - resolution: {integrity: sha512-zSw6vHOQYP5FOgbM1pvOerO6/022HFQUfzGCAb+CFu5iDq2Skpc1BI0DASOKBTTklwuXMHzDmsCJFHF07c9nuA==} + '@nomicfoundation/hardhat-errors@3.0.17': + resolution: {integrity: sha512-x8/Bv7Mn0a90ZRX4ZfWuq8uGuqF10LzLMXD1LD0kEIRBwSvr71fcxYyONcuf+MzznbrJ+WBTNz3ov9Rd++8DfQ==} + + '@nomicfoundation/hardhat-network-helpers@3.0.11': + resolution: {integrity: sha512-3/xuORejAOGbfqBmIen+OCHu9ExK9O8pjE11ILU9NcN37zy3GDbyNCKV9keK1gSjWWVb0okAlAR/yvewfEE4TA==} peerDependencies: hardhat: ^3.8.0 @@ -542,6 +601,9 @@ packages: '@nomicfoundation/hardhat-utils@4.1.3': resolution: {integrity: sha512-SYDKX6SCdzs/5mC/S1D+AjNSQJrhxevHTI24TIouIjO0Xs0dB0+S4cYCqHWOgvnHLshrcrJ7XGUjS7+apWa1Tw==} + '@nomicfoundation/hardhat-utils@4.1.5': + resolution: {integrity: sha512-EokhnMFDkDQPSsxrzyDAuCJuMbEsNWfMIMPgeB+FY8wnArl+dZR7tkd5UV2iooSlyxF+H3zj/0RKReXscTyijQ==} + '@nomicfoundation/hardhat-vendored@3.0.4': resolution: {integrity: sha512-RO8Otj1FvRvxJmXzkxh1vTwK/+cqSVPYLqY6RrWkmzHEEcxnAwAFsBYdW7xyTEyW/pVbSSNd2gs3aoGdGZaoNA==} @@ -1264,8 +1326,8 @@ packages: '@nomicfoundation/hardhat-viem': ^3 hardhat: ^3 - hardhat@3.9.0: - resolution: {integrity: sha512-MjLHYgxCbqhpLfu6FBkHkhTu9igJmtwgILJwdw7XUZ3xjnXLdGeXtwhykPA16hjeRrmWZH4yv5Tv4maw0dJASw==} + hardhat@3.9.1: + resolution: {integrity: sha512-yg+0oH5tWqdsxITh6fAJjAWOSHOkC2VPlsJDJwoifs2QS1t7kyRciMy5O2F846qzH+4iqRn1rbv/5voykX3RSQ==} hasBin: true has-flag@3.0.0: @@ -2296,6 +2358,41 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + '@chainsafe/is-ip@2.1.0': {} '@chainsafe/netmask@2.0.0': @@ -2698,52 +2795,56 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nomicfoundation/edr-darwin-arm64@0.12.0': {} + '@nomicfoundation/edr-darwin-arm64@0.12.1': {} - '@nomicfoundation/edr-darwin-x64@0.12.0': {} + '@nomicfoundation/edr-darwin-x64@0.12.1': {} - '@nomicfoundation/edr-linux-arm64-gnu@0.12.0': {} + '@nomicfoundation/edr-linux-arm64-gnu@0.12.1': {} - '@nomicfoundation/edr-linux-arm64-musl@0.12.0': {} + '@nomicfoundation/edr-linux-arm64-musl@0.12.1': {} - '@nomicfoundation/edr-linux-x64-gnu@0.12.0': {} + '@nomicfoundation/edr-linux-x64-gnu@0.12.1': {} - '@nomicfoundation/edr-linux-x64-musl@0.12.0': {} + '@nomicfoundation/edr-linux-x64-musl@0.12.1': {} - '@nomicfoundation/edr-win32-x64-msvc@0.12.0': {} + '@nomicfoundation/edr-win32-x64-msvc@0.12.1': {} - '@nomicfoundation/edr@0.12.0': + '@nomicfoundation/edr@0.12.1': dependencies: - '@nomicfoundation/edr-darwin-arm64': 0.12.0 - '@nomicfoundation/edr-darwin-x64': 0.12.0 - '@nomicfoundation/edr-linux-arm64-gnu': 0.12.0 - '@nomicfoundation/edr-linux-arm64-musl': 0.12.0 - '@nomicfoundation/edr-linux-x64-gnu': 0.12.0 - '@nomicfoundation/edr-linux-x64-musl': 0.12.0 - '@nomicfoundation/edr-win32-x64-msvc': 0.12.0 + '@nomicfoundation/edr-darwin-arm64': 0.12.1 + '@nomicfoundation/edr-darwin-x64': 0.12.1 + '@nomicfoundation/edr-linux-arm64-gnu': 0.12.1 + '@nomicfoundation/edr-linux-arm64-musl': 0.12.1 + '@nomicfoundation/edr-linux-x64-gnu': 0.12.1 + '@nomicfoundation/edr-linux-x64-musl': 0.12.1 + '@nomicfoundation/edr-win32-x64-msvc': 0.12.1 '@nomicfoundation/hardhat-errors@3.0.15': dependencies: '@nomicfoundation/hardhat-utils': 4.1.3 - '@nomicfoundation/hardhat-network-helpers@3.0.10(hardhat@3.9.0)': + '@nomicfoundation/hardhat-errors@3.0.17': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.15 - '@nomicfoundation/hardhat-utils': 4.1.3 - hardhat: 3.9.0 + '@nomicfoundation/hardhat-utils': 4.1.5 + + '@nomicfoundation/hardhat-network-helpers@3.0.11(hardhat@3.9.1)': + dependencies: + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + hardhat: 3.9.1 '@nomicfoundation/hardhat-node-test-reporter@3.1.0': dependencies: '@actions/core': 1.11.1 jest-diff: 29.7.0 - '@nomicfoundation/hardhat-node-test-runner@3.0.17(hardhat@3.9.0)': + '@nomicfoundation/hardhat-node-test-runner@3.0.17(hardhat@3.9.1)': dependencies: '@nomicfoundation/hardhat-errors': 3.0.15 '@nomicfoundation/hardhat-node-test-reporter': 3.1.0 '@nomicfoundation/hardhat-utils': 4.1.3 '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) - hardhat: 3.9.0 + hardhat: 3.9.1 tsx: 4.22.4 zod: 3.25.76 @@ -2757,13 +2858,23 @@ snapshots: rfdc: 1.4.1 undici: 6.26.0 + '@nomicfoundation/hardhat-utils@4.1.5': + dependencies: + '@streamparser/json-node': 0.0.22 + env-paths: 2.2.1 + ethereum-cryptography: 2.2.1 + fast-equals: 5.4.0 + json-stream-stringify: 3.1.6 + rfdc: 1.4.1 + undici: 6.26.0 + '@nomicfoundation/hardhat-vendored@3.0.4': {} - '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': dependencies: '@nomicfoundation/hardhat-errors': 3.0.15 '@nomicfoundation/hardhat-utils': 4.1.3 - hardhat: 3.9.0 + hardhat: 3.9.1 viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-zod-utils@3.0.5(zod@3.25.76)': @@ -3571,18 +3682,18 @@ 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.10(hardhat@3.9.0))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.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): dependencies: - '@nomicfoundation/hardhat-network-helpers': 3.0.10(hardhat@3.9.0) - '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) - hardhat: 3.9.0 + '@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-ts: link:../matchstick-ts - hardhat@3.9.0: + hardhat@3.9.1: dependencies: - '@nomicfoundation/edr': 0.12.0 - '@nomicfoundation/hardhat-errors': 3.0.15 - '@nomicfoundation/hardhat-utils': 4.1.3 + '@nomicfoundation/edr': 0.12.1 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 '@nomicfoundation/hardhat-vendored': 3.0.4 '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) '@nomicfoundation/solidity-analyzer': 0.1.2 From cf72bd479411725c242bbaa9901f0b4bed28690c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:20:24 +0200 Subject: [PATCH 107/180] chore: add typecheck CI gates for indexer packages Add points-indexer tsgo typecheck (integration) and treat vault indexer graph build as its typecheck gate. Align contracts/points-indexer viem so cross-package fixture typing stays consistent. Co-authored-by: Cursor --- .github/workflows/indexer-tests.yml | 4 +- .github/workflows/points-indexer-tests.yml | 4 + biome.json | 3 +- contracts/package.json | 2 +- contracts/pnpm-lock.yaml | 131 +++++++----------- indexer/package.json | 3 +- .../integration/points-mint.test.ts | 8 +- .../integration/points-redemption.test.ts | 12 +- points-indexer/integration/tsconfig.json | 23 ++- points-indexer/package.json | 6 +- points-indexer/pnpm-lock.yaml | 83 ++++++++++- 11 files changed, 176 insertions(+), 103 deletions(-) diff --git a/.github/workflows/indexer-tests.yml b/.github/workflows/indexer-tests.yml index 85e245c..eef8d5d 100644 --- a/.github/workflows/indexer-tests.yml +++ b/.github/workflows/indexer-tests.yml @@ -75,8 +75,8 @@ jobs: - name: Lint run: pnpm lint - - name: Build - run: pnpm build + - name: Typecheck + run: pnpm typecheck - name: Run matchstick tests run: pnpm test diff --git a/.github/workflows/points-indexer-tests.yml b/.github/workflows/points-indexer-tests.yml index fd6cb80..d71c51a 100644 --- a/.github/workflows/points-indexer-tests.yml +++ b/.github/workflows/points-indexer-tests.yml @@ -89,6 +89,10 @@ jobs: working-directory: ./points-indexer run: pnpm lint + - name: Typecheck + working-directory: ./points-indexer + run: pnpm typecheck + - name: Build working-directory: ./points-indexer run: pnpm build diff --git a/biome.json b/biome.json index 305aaf5..0bd64e8 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,8 @@ "!!**/node_modules", "!!**/coverage", "!!**/cache", - "!!**/artifacts" + "!!**/artifacts", + "!!**/dist" ] }, "overrides": [ diff --git a/contracts/package.json b/contracts/package.json index 67b030a..c2ed643 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -33,7 +33,7 @@ "@openzeppelin/contracts": "npm:@openzeppelin/contracts@5.1.0", "@openzeppelin/contracts-upgradeable": "npm:@openzeppelin/contracts-upgradeable@5.1.0", "dotenv": "^16.4.1", - "viem": "^2.42.1" + "viem": "^2.52.2" }, "packageManager": "pnpm@10.28.1" } \ No newline at end of file diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 2dbe251..b225172 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -18,21 +18,21 @@ importers: specifier: ^16.4.1 version: 16.6.1 viem: - specifier: ^2.42.1 - version: 2.47.10(typescript@5.9.3)(zod@3.25.76) + specifier: ^2.52.2 + version: 2.52.2(typescript@5.9.3)(zod@3.25.76) devDependencies: '@biomejs/biome': specifier: ^2.4.10 version: 2.4.10 '@nomicfoundation/hardhat-toolbox-viem': specifier: ^5.0.7 - version: 5.0.7(a7c1ad8adca5d73f1855378c51cc72e8) + version: 5.0.7(60d46a9bbe0fb85b88e4a35be160bb97) '@nomicfoundation/hardhat-verify': specifier: ^3.0.17 version: 3.0.17(hardhat@3.9.1) '@nomicfoundation/hardhat-viem': specifier: 3.0.9 - version: 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + version: 3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -414,9 +414,6 @@ packages: resolution: {integrity: sha512-1U8C+kiVMIbVkOW+Sa7sUm9glSaB5cMe7UJ9wCOHFPpBBUQgStgrgAOWOahRL0vKRUjHUpuQpg47cRcUSdmW/A==} engines: {node: '>= 20'} - '@nomicfoundation/hardhat-errors@3.0.11': - resolution: {integrity: sha512-XEKplQ+FhZD1PgIGSj62scoqB/y+uG8x+V+U68m1a+4L1I46y4/gZQGIuMLkRZeqhPHsLle6ykDB+vn8qtwqzw==} - '@nomicfoundation/hardhat-errors@3.0.17': resolution: {integrity: sha512-x8/Bv7Mn0a90ZRX4ZfWuq8uGuqF10LzLMXD1LD0kEIRBwSvr71fcxYyONcuf+MzznbrJ+WBTNz3ov9Rd++8DfQ==} @@ -469,9 +466,6 @@ packages: hardhat: ^3.8.0 viem: ^2.47.6 - '@nomicfoundation/hardhat-utils@4.0.5': - resolution: {integrity: sha512-+M4gdNhj5zqElduDckzFza4oBXEwYaLdMeJuIqprDwb4iQkDxLVeKQgIWFp1SdRCqJ116QSDYFVZApznHdcoZg==} - '@nomicfoundation/hardhat-utils@4.1.5': resolution: {integrity: sha512-EokhnMFDkDQPSsxrzyDAuCJuMbEsNWfMIMPgeB+FY8wnArl+dZR7tkd5UV2iooSlyxF+H3zj/0RKReXscTyijQ==} @@ -835,8 +829,8 @@ packages: engines: {node: '>=10'} hasBin: true - ox@0.14.7: - resolution: {integrity: sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==} + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -938,8 +932,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - viem@2.47.10: - resolution: {integrity: sha512-D+l6SDDZWB5bh8u9hgICzMX2/egMrgEQ+Pef/QkZgmOl6bOTyCQMSgWAH8jZTWJ/218J9QNv7s/9BH6Wu5oPDg==} + viem@2.52.2: + resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -958,8 +952,8 @@ packages: utf-8-validate: optional: true - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -970,8 +964,8 @@ packages: utf-8-validate: optional: true - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1311,32 +1305,24 @@ snapshots: '@nomicfoundation/edr-linux-x64-musl': 0.12.1 '@nomicfoundation/edr-win32-x64-msvc': 0.12.1 - '@nomicfoundation/hardhat-errors@3.0.11': - dependencies: - '@nomicfoundation/hardhat-utils': 4.0.5 - transitivePeerDependencies: - - supports-color - '@nomicfoundation/hardhat-errors@3.0.17': dependencies: '@nomicfoundation/hardhat-utils': 4.1.5 - '@nomicfoundation/hardhat-ignition-viem@3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-ignition-viem@3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(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)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 + '@nomicfoundation/hardhat-errors': 3.0.17 '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1) '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) - '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@nomicfoundation/ignition-core': 3.1.1 hardhat: 3.9.1 - viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) - transitivePeerDependencies: - - supports-color + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1)': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) '@nomicfoundation/ignition-core': 3.1.1 '@nomicfoundation/ignition-ui': 3.1.1 @@ -1354,9 +1340,9 @@ snapshots: dependencies: '@noble/ciphers': 1.2.1 '@noble/hashes': 1.7.1 - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) chalk: 5.6.2 debug: 4.4.3 hardhat: 3.9.1 @@ -1366,11 +1352,9 @@ snapshots: '@nomicfoundation/hardhat-network-helpers@3.0.4(hardhat@3.9.1)': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 hardhat: 3.9.1 - transitivePeerDependencies: - - supports-color '@nomicfoundation/hardhat-node-test-reporter@3.0.3': dependencies: @@ -1380,42 +1364,27 @@ snapshots: '@nomicfoundation/hardhat-node-test-runner@3.0.12(hardhat@3.9.1)': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 + '@nomicfoundation/hardhat-errors': 3.0.17 '@nomicfoundation/hardhat-node-test-reporter': 3.0.3 - '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-zod-utils': 3.0.4(zod@3.25.76) + '@nomicfoundation/hardhat-utils': 4.1.5 + '@nomicfoundation/hardhat-zod-utils': 3.0.5(zod@3.25.76) hardhat: 3.9.1 tsx: 4.21.0 zod: 3.25.76 - transitivePeerDependencies: - - supports-color - '@nomicfoundation/hardhat-toolbox-viem@5.0.7(a7c1ad8adca5d73f1855378c51cc72e8)': + '@nomicfoundation/hardhat-toolbox-viem@5.0.7(60d46a9bbe0fb85b88e4a35be160bb97)': dependencies: '@nomicfoundation/hardhat-ignition': 3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1) - '@nomicfoundation/hardhat-ignition-viem': 3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-ignition-viem': 3.1.1(@nomicfoundation/hardhat-ignition@3.1.1(@nomicfoundation/hardhat-verify@3.0.17(hardhat@3.9.1))(hardhat@3.9.1))(@nomicfoundation/hardhat-verify@3.0.17(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)))(@nomicfoundation/ignition-core@3.1.1)(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@nomicfoundation/hardhat-keystore': 3.0.5(hardhat@3.9.1) '@nomicfoundation/hardhat-network-helpers': 3.0.4(hardhat@3.9.1) '@nomicfoundation/hardhat-node-test-runner': 3.0.12(hardhat@3.9.1) '@nomicfoundation/hardhat-verify': 3.0.17(hardhat@3.9.1) - '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) - '@nomicfoundation/hardhat-viem-assertions': 3.0.7(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-viem-assertions': 3.0.7(@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)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) '@nomicfoundation/ignition-core': 3.1.1 hardhat: 3.9.1 - viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) - - '@nomicfoundation/hardhat-utils@4.0.5': - dependencies: - '@streamparser/json-node': 0.0.22 - debug: 4.4.3 - env-paths: 2.2.1 - ethereum-cryptography: 2.2.1 - fast-equals: 5.4.0 - json-stream-stringify: 3.1.6 - rfdc: 1.4.1 - undici: 6.24.1 - transitivePeerDependencies: - - supports-color + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-utils@4.1.5': dependencies: @@ -1439,22 +1408,20 @@ snapshots: hardhat: 3.9.1 zod: 3.25.76 - '@nomicfoundation/hardhat-viem-assertions@3.0.7(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-viem-assertions@3.0.7(@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)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': dependencies: - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 - '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76)) + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + '@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 - viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) - transitivePeerDependencies: - - supports-color + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) - '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.47.10(typescript@5.9.3)(zod@3.25.76))': + '@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))': dependencies: '@nomicfoundation/hardhat-errors': 3.0.17 '@nomicfoundation/hardhat-utils': 4.1.5 hardhat: 3.9.1 - viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) '@nomicfoundation/hardhat-zod-utils@3.0.4(zod@3.25.76)': dependencies: @@ -1471,8 +1438,8 @@ snapshots: '@nomicfoundation/ignition-core@3.1.1': dependencies: '@ethersproject/address': 5.6.1 - '@nomicfoundation/hardhat-errors': 3.0.11 - '@nomicfoundation/hardhat-utils': 4.0.5 + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 '@nomicfoundation/solidity-analyzer': 0.1.2 cbor2: 1.12.0 debug: 4.4.3 @@ -1769,9 +1736,9 @@ snapshots: inherits@2.0.4: {} - isows@1.0.7(ws@8.18.3): + isows@1.0.7(ws@8.20.1): dependencies: - ws: 8.18.3 + ws: 8.20.1 jest-diff@29.7.0: dependencies: @@ -1820,7 +1787,7 @@ snapshots: split2: 3.2.2 through2: 4.0.2 - ox@0.14.7(typescript@5.9.3)(zod@3.25.76): + ox@0.14.29(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -1915,16 +1882,16 @@ snapshots: util-deprecate@1.0.2: {} - viem@2.47.10(typescript@5.9.3)(zod@3.25.76): + viem@2.52.2(typescript@5.9.3)(zod@3.25.76): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - isows: 1.0.7(ws@8.18.3) - ox: 0.14.7(typescript@5.9.3)(zod@3.25.76) - ws: 8.18.3 + isows: 1.0.7(ws@8.20.1) + ox: 0.14.29(typescript@5.9.3)(zod@3.25.76) + ws: 8.20.1 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -1934,8 +1901,8 @@ snapshots: ws@8.17.1: {} - ws@8.18.3: {} - ws@8.20.0: {} + ws@8.20.1: {} + zod@3.25.76: {} diff --git a/indexer/package.json b/indexer/package.json index ca5b5da..3d9f116 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -20,7 +20,8 @@ "lint": "biome lint .", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground", - "lint:fix": "biome check --write ." + "lint:fix": "biome check --write .", + "typecheck": "graph build" }, "dependencies": { "@graphprotocol/graph-ts": "0.38.2" diff --git a/points-indexer/integration/points-mint.test.ts b/points-indexer/integration/points-mint.test.ts index da395cd..eca7d49 100644 --- a/points-indexer/integration/points-mint.test.ts +++ b/points-indexer/integration/points-mint.test.ts @@ -36,7 +36,7 @@ describe("onFill accrual: maker + taker mints mirrored to the leaderboard", () = // alice = maker, bob = taker. Both fees above threshold → both sides mint. await hook.write.onFill([alice.account.address, bob.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { - account: venue.account, + account: venue.account.address, chain: null, }); const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; @@ -100,11 +100,11 @@ describe("onFill accrual: a self-match contributes nothing to the leaderboard", // A self-match by alice (maker == taker) mints nothing... await hook.write.onFill([alice.account.address, alice.account.address, NOTIONAL, FEE, FEE, 0n, 0n], { - account: venue.account, + account: venue.account.address, chain: null, }); // ...while a real fill (carol maker w/ 0 fee → no maker mint; bob takes) mints once. await hook.write.onFill([carol.account.address, bob.account.address, NOTIONAL, 0n, FEE, 0n, 0n], { - account: venue.account, + account: venue.account.address, chain: null, }); const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; @@ -145,7 +145,7 @@ describe("onLiquidation accrual: flat keeper points mirrored", () => { await conn.matchstick.captureViewMocks(); await conn.matchstick.anchor(); - await hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account }); + await hook.write.onLiquidation([keeper.account.address, FEE], { account: venue.account.address, chain: null }); const keeperAddr = keeper.account.address.toLowerCase() as `0x${string}`; const snap = await conn.matchstick.indexSnapshot([ diff --git a/points-indexer/integration/points-redemption.test.ts b/points-indexer/integration/points-redemption.test.ts index bea82d2..b6edbbb 100644 --- a/points-indexer/integration/points-redemption.test.ts +++ b/points-indexer/integration/points-redemption.test.ts @@ -41,21 +41,21 @@ describe.skip("swap: burn debits supply, Swapped records the GOV payout split", await hook.write.onFill( [carol.account.address, alice.account.address, NOTIONAL, 0n, FEE, 0n, 0n], { - account: venue.account, + account: venue.account.address, chain: null, }, ); await hook.write.onFill( [carol.account.address, bob.account.address, NOTIONAL * 3n, 0n, FEE, 0n, 0n], { - account: venue.account, + account: venue.account.address, chain: null, }, ); // Wind down: finalize, fund the pool, open redemption, then alice swaps. - await points.write.finalize({ account: owner.account }); - await gov.write.transfer([redeemer.address, POOL], { account: owner.account }); - await redeemer.write.enableRedemption([POOL], { account: owner.account }); - await redeemer.write.swap({ account: alice.account }); + await points.write.finalize({ account: owner.account.address, chain: null }); + await gov.write.transfer([redeemer.address, POOL], { account: owner.account.address, chain: null }); + await redeemer.write.enableRedemption([POOL], { account: owner.account.address, chain: null }); + await redeemer.write.swap({ account: alice.account.address, chain: null }); const aliceAddr = alice.account.address.toLowerCase() as `0x${string}`; const expectedGov = (POOL * ALICE_PTS) / (ALICE_PTS + BOB_PTS); // 1000 GOV diff --git a/points-indexer/integration/tsconfig.json b/points-indexer/integration/tsconfig.json index fc83c1d..27525ca 100644 --- a/points-indexer/integration/tsconfig.json +++ b/points-indexer/integration/tsconfig.json @@ -1,8 +1,25 @@ { "extends": "../../contracts/tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": [ + "node" + ], + "paths": { + "viem": [ + "./node_modules/viem" + ], + "viem/*": [ + "./node_modules/viem/*" + ] + } }, - "include": ["../hardhat.config.ts", "./**/*.ts", "../../contracts/tests/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "../hardhat.config.ts", + "./**/*.ts", + "../../contracts/tests/pointsIntegrationFixtures.ts", + "../../contracts/artifacts/**/*.d.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/points-indexer/package.json b/points-indexer/package.json index 15ba35e..34e8f44 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -22,7 +22,8 @@ "lint": "biome lint .", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground", - "lint:fix": "biome check --write ." + "lint:fix": "biome check --write .", + "typecheck": "tsgo --noEmit -p integration/tsconfig.json" }, "dependencies": { "@graphprotocol/graph-ts": "0.38.2" @@ -34,6 +35,7 @@ "@nomicfoundation/hardhat-node-test-runner": "3.0.17", "@nomicfoundation/hardhat-viem": "3.0.9", "@types/node": "^25.3.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "assemblyscript": "^0.19.23", "collateral-margin-contracts": "link:../contracts", "hardhat": "^3.9.1", @@ -41,7 +43,7 @@ "matchstick-as": "0.6.0", "matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/matchstick-ts", "typescript": "^5.9.3", - "viem": "^2.42.1" + "viem": "2.52.2" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml index 11df585..2029541 100644 --- a/points-indexer/pnpm-lock.yaml +++ b/points-indexer/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@types/node': specifier: ^25.3.0 version: 25.9.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260707.2 + version: 7.0.0-dev.20260707.2 assemblyscript: specifier: ^0.19.23 version: 0.19.23 @@ -52,7 +55,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 viem: - specifier: ^2.42.1 + specifier: 2.52.2 version: 2.52.2(typescript@5.9.3)(zod@3.25.76) packages: @@ -734,6 +737,53 @@ packages: '@types/ws@7.4.7': resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} + engines: {node: '>=16.20.0'} + hasBin: true + '@whatwg-node/disposablestack@0.0.6': resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} engines: {node: '>=18.0.0'} @@ -3053,6 +3103,37 @@ snapshots: dependencies: '@types/node': 25.9.2 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260707.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 + '@whatwg-node/disposablestack@0.0.6': dependencies: '@whatwg-node/promise-helpers': 1.3.2 From 3929f17a1016ac06f6b1f2a0000e389c3329bc3d Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:23:22 +0200 Subject: [PATCH 108/180] fix(keeper): restore path:/contracts/abi in futures ABI lockfile pnpm rewrote the git dep resolution to integrity-only during the lint tooling bump, which unpacks the full futures repo in CI and breaks the Futures.ts layout check. Co-authored-by: Cursor --- keeper/pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 15ca5d5..ebc3375 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -226,7 +226,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} version: 0.0.0 help-me@5.0.0: From 92535953579e084491820b46e903c07efe11720a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:24:27 +0200 Subject: [PATCH 109/180] fix(keeper): restore path:/contracts/abi for perps ABI lockfile Same pnpm rewrite dropped the path field from derivatives-marketplace-abi, so cold CI installs unpack the full repo and typecheck cannot resolve HashPowerPerpsDEX.ts. Co-authored-by: Cursor --- keeper/pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index ebc3375..1b20ff6 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -210,7 +210,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} version: 0.0.0 end-of-stream@1.4.5: From d9862bc1e9f56df1e98ea3c4a503c1857894b68a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:47:26 +0200 Subject: [PATCH 110/180] feat(mm): use reduce-only amends in updateOrders Drop createCallWeight/maxCallsPerTx splitting and encode reduces alongside cancels/creates so size decreases keep FIFO. Point keeper/MM contract git deps at the reduce-order commits. --- keeper/package.json | 4 +- market-maker/configs/portfolio.dev.yml | 1 - market-maker/configs/portfolio.local.yml | 1 - market-maker/configs/portfolio.prd.yml | 1 - market-maker/configs/portfolio.stg.yml | 1 - market-maker/package.json | 4 +- market-maker/schemas/portfolio.json | 16 --- .../src/adapters/futures/instrument.ts | 63 +++++++---- .../src/adapters/futures/ownOrders.ts | 15 ++- market-maker/src/adapters/perps/instrument.ts | 49 +++++---- market-maker/src/apps/portfolio/config.ts | 8 -- market-maker/src/apps/portfolio/main.ts | 2 +- market-maker/src/core/adapter.ts | 48 ++++----- market-maker/src/core/bookTracker.ts | 12 ++- market-maker/src/core/marketRuntime.ts | 1 + market-maker/src/core/orderExecutor.ts | 97 ++++++++++------- market-maker/src/core/portfolioRunner.ts | 4 +- market-maker/src/core/txCoordinator.ts | 100 ++++-------------- .../tests/apps/portfolio/config.test.ts | 1 - market-maker/tests/core/marketRuntime.test.ts | 27 ++++- market-maker/tests/core/oracleTracker.test.ts | 1 - market-maker/tests/core/orderExecutor.test.ts | 29 +++-- .../tests/core/portfolioRunner.test.ts | 3 + market-maker/tests/core/txCoordinator.test.ts | 57 ++++++---- 24 files changed, 289 insertions(+), 256 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index 88bf294..9b2e425 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -23,8 +23,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index 69c5b66..977dbaf 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -127,7 +127,6 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. Lowered from 50 for base-sepolia. - maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index 2566f39..d36be0c 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -105,7 +105,6 @@ health: # Centralized submission / nonce recovery. txCoordinator: - maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index 37736e7..a8f84cd 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -119,7 +119,6 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. - maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml index 6a8ff35..c4334a5 100644 --- a/market-maker/configs/portfolio.stg.yml +++ b/market-maker/configs/portfolio.stg.yml @@ -118,7 +118,6 @@ txCoordinator: # perps = one order per price level; futures = one contract (qty=1). Futures # createOrder gas scales with qty, so a full 3-expiry quote is many units and # must be chunked to avoid out-of-gas. - maxCallsPerTx: 100 confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 diff --git a/market-maker/package.json b/market-maker/package.json index 8ccf22b..eb810a0 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -39,9 +39,9 @@ "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#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index d399cb1..802db5c 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -1241,28 +1241,12 @@ "description": "Centralized submission / nonce recovery.", "type": "object", "required": [ - "maxCallsPerTx", "confirmationTimeoutSec", "maxReplacements", "replacementFeeBumpPct", "maxNonceResyncs" ], "properties": { - "maxCallsPerTx": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Max cost units per tx before chunking (not raw call count).", - "default": 100 - }, "confirmationTimeoutSec": { "default": 60, "description": "Seconds to wait for a tx receipt before replacing by fee.", diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index dcd6909..e750048 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -11,6 +11,7 @@ import type { OrderBookSnapshot, OrderIntent, Position, + ReduceIntent, } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; @@ -97,12 +98,10 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { encodeUpdateOrders( cancels: CancelIntent[], + reduces: ReduceIntent[], creates: OrderIntent[], ): `0x${string}` { - if (cancels.length === 0 && creates.length === 0) { - throw new Error("futures: encodeUpdateOrders requires cancels and/or creates"); - } - // Local ABI fragment until published futures-contracts includes updateOrders. + // Local ABI fragment until published futures-contracts includes the reduces arg. const updateOrdersAbi = [ { type: "function", @@ -110,6 +109,14 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { stateMutability: "nonpayable", inputs: [ { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_reduces", + type: "tuple[]", + components: [ + { name: "orderId", type: "bytes32" }, + { name: "newQuantity", type: "int256" }, + ], + }, { name: "_intents", type: "tuple[]", @@ -123,6 +130,15 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { outputs: [], }, ] as const; + const reduceBatch = reduces.map((r) => { + if (r.newSize <= 0n) { + throw new Error(`futures: reduce newSize ${r.newSize} must be > 0`); + } + return { + orderId: r.orderId, + newQuantity: r.side === "buy" ? r.newSize : -r.newSize, + }; + }); const batch = creates.map((intent) => { const qty = intent.size; if (qty <= 0n) { @@ -137,7 +153,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { return encodeFunctionData({ abi: updateOrdersAbi, functionName: "updateOrders", - args: [cancels.map((c) => c.orderId), batch], + args: [cancels.map((c) => c.orderId), reduceBatch, batch], }); } @@ -150,26 +166,23 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** - * Cost units = qty. A futures create does work proportional to matched / - * resting contracts, so gas scales with qty (a qty=1 create ≈ one cancel). - */ - createCallWeight(intent: OrderIntent): number { - return Number(intent.size); - } - - /** - * Execute cancels then creates for this expiry via `updateOrders`. Kept for - * single-market callers and tests; the portfolio runner routes through the - * shared `TxCoordinator` instead. + * Execute cancels + reduces + creates via `updateOrders` (IM checked once). */ async executeOrders(intent: ExecuteOrdersIntent): Promise { return this.executeOrdersImpl(intent, this.venue.getLogger()); } /** Build the call list for this expiry: one `updateOrders` when there is work. */ - buildCalls(intent: { cancels: CancelIntent[]; creates: OrderIntent[] }): `0x${string}`[] { - if (intent.cancels.length === 0 && intent.creates.length === 0) return []; - return [this.encodeUpdateOrders(intent.cancels, intent.creates)]; + buildCalls(intent: { + cancels: CancelIntent[]; + reduces?: ReduceIntent[]; + creates: OrderIntent[]; + }): `0x${string}`[] { + const reduces = intent.reduces ?? []; + if (intent.cancels.length === 0 && reduces.length === 0 && intent.creates.length === 0) { + return []; + } + return [this.encodeUpdateOrders(intent.cancels, reduces, intent.creates)]; } // ── Private implementation ────────────────────────────────────────── @@ -178,14 +191,19 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - if (intent.cancels.length === 0 && intent.creates.length === 0) { + const reduces = intent.reduces ?? []; + if (intent.cancels.length === 0 && reduces.length === 0 && intent.creates.length === 0) { return { receipts: [], errors: [] }; } - const data = this.encodeUpdateOrders(intent.cancels, intent.creates); + const data = this.encodeUpdateOrders(intent.cancels, reduces, intent.creates); if (intent.dryRun) { logger.info( - { cancels: intent.cancels.length, creates: intent.creates.length }, + { + cancels: intent.cancels.length, + reduces: reduces.length, + creates: intent.creates.length, + }, "DRY RUN: would send updateOrders", ); return { receipts: [], errors: [] }; @@ -201,6 +219,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { logger.info( { cancels: intent.cancels.length, + reduces: reduces.length, creates: intent.creates.length, gas: receipt.gasUsed.toString(), }, diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 321ca56..9c31311 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -141,13 +141,26 @@ export class FuturesOwnOrders implements OwnOrderSource { orderId: evt.orderId, price: evt.price, side: evt.side, - size: 1n, + size: evt.size, instrumentId: this.instrumentId, }; this.cache.set(evt.orderId, order); this.notify({ type: "added", orderId: evt.orderId, order }); return; } + if (evt.type === "order-updated") { + const existing = this.cache.get(evt.orderId); + if (!existing) return; + if (evt.newSize === 0n) { + this.cache.delete(evt.orderId); + this.notify({ type: "removed", orderId: evt.orderId }); + return; + } + const order: OwnOrder = { ...existing, size: evt.newSize }; + this.cache.set(evt.orderId, order); + this.notify({ type: "updated", orderId: evt.orderId, order }); + return; + } if (evt.type === "order-cancelled") { // No expiry on the close event: apply only if this cache owns the id. if (!this.cache.has(evt.orderId)) return; diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index ae17dbe..044570d 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -14,6 +14,7 @@ import type { OwnOrderEvent, OwnOrderSource, Position, + ReduceIntent, Unsubscribe, } from "../../core/adapter.ts"; import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.ts"; @@ -77,12 +78,10 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { encodeUpdateOrders( cancels: CancelIntent[], + reduces: ReduceIntent[], creates: OrderIntent[], ): `0x${string}` { - if (cancels.length === 0 && creates.length === 0) { - throw new Error("perps: encodeUpdateOrders requires cancels and/or creates"); - } - // Local ABI fragment until published perps-contracts includes updateOrders. + // Local ABI fragment until published perps-contracts includes the reduces arg. const updateOrdersAbi = [ { type: "function", @@ -90,6 +89,14 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { stateMutability: "nonpayable", inputs: [ { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_reduces", + type: "tuple[]", + components: [ + { name: "orderId", type: "bytes32" }, + { name: "newQuantity", type: "int256" }, + ], + }, { name: "_intents", type: "tuple[]", @@ -102,6 +109,15 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { outputs: [], }, ] as const; + const reduceBatch = reduces.map((r) => { + if (r.newSize <= 0n) { + throw new Error(`perps: reduce newSize ${r.newSize} must be > 0`); + } + return { + orderId: r.orderId, + newQuantity: r.side === "buy" ? r.newSize : -r.newSize, + }; + }); const batch = creates.map((intent) => ({ price: intent.price, quantity: intent.side === "buy" ? intent.size : -intent.size, @@ -109,7 +125,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { return encodeFunctionData({ abi: updateOrdersAbi, functionName: "updateOrders", - args: [cancels.map((c) => c.orderId), batch], + args: [cancels.map((c) => c.orderId), reduceBatch, batch], }); } @@ -122,15 +138,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } /** - * One cost unit per order: a perps `createOrder` is a single price-level - * insertion whose gas is independent of the order's size. - */ - createCallWeight(_intent: OrderIntent): number { - return 1; - } - - /** - * Execute cancels + creates via `updateOrders` (IM checked once on-chain). + * Execute cancels + reduces + creates via `updateOrders` (IM checked once). */ async executeOrders( intent: ExecuteOrdersIntent, @@ -148,16 +156,18 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - if (intent.cancels.length === 0 && intent.creates.length === 0) { + const reduces = intent.reduces ?? []; + if (intent.cancels.length === 0 && reduces.length === 0 && intent.creates.length === 0) { return { receipts: [], errors: [] }; } - const data = this.encodeUpdateOrders(intent.cancels, intent.creates); + const data = this.encodeUpdateOrders(intent.cancels, reduces, intent.creates); if (intent.dryRun) { logger.info( { cancels: intent.cancels.length, + reduces: reduces.length, creates: intent.creates.length, }, "DRY RUN: would send updateOrders", @@ -175,6 +185,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { logger.info( { cancels: intent.cancels.length, + reduces: reduces.length, creates: intent.creates.length, gas: receipt.gasUsed.toString(), }, @@ -365,13 +376,11 @@ class PerpsOwnOrders implements OwnOrderSource { } case "order-updated": { if (evt.participant.toLowerCase() !== own) return; - // We don't have side/price from the update event; signal a refresh - // is needed by emitting "removed". The next BookTracker resync will - // re-pick it up via list() if it still exists. if (evt.newSize === 0n) { cb({ type: "removed", orderId: evt.orderId }); } else { - cb({ type: "updated", orderId: evt.orderId }); + // Price/side come from BookTracker's existing entry; patch size only. + cb({ type: "updated", orderId: evt.orderId, newSize: evt.newSize }); } return; } diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts index 158c040..bd7dd44 100644 --- a/market-maker/src/apps/portfolio/config.ts +++ b/market-maker/src/apps/portfolio/config.ts @@ -104,11 +104,6 @@ const futuresVenueSchema = Type.Object( const txCoordinatorSchema = Type.Object( { - maxCallsPerTx: Type.Integer({ - minimum: 1, - default: 100, - description: "Max cost units per tx before chunking (not raw call count).", - }), confirmationTimeoutSec: TypeSeconds({ minimum: 1, default: 60, @@ -254,7 +249,6 @@ export interface ParsedFuturesVenue { export type ParsedVenue = ParsedPerpsVenue | ParsedFuturesVenue; export interface ParsedTxCoordinatorConfig { - maxCallsPerTx: number; confirmationTimeoutMs: number; maxReplacements: number; replacementFeeBumpPct: number; @@ -325,7 +319,6 @@ export function loadPortfolioConfig( env: opts.env, parse: (raw) => { const tx = raw.txCoordinator ?? { - maxCallsPerTx: 100, confirmationTimeoutSec: 60, maxReplacements: 2, replacementFeeBumpPct: 15, @@ -344,7 +337,6 @@ export function loadPortfolioConfig( oracle: parseOracleConfig(raw.oracle), venues: raw.venues.map(parseVenue), txCoordinator: { - maxCallsPerTx: tx.maxCallsPerTx, confirmationTimeoutMs: secondsToMs(tx.confirmationTimeoutSec, "txCoordinator.confirmationTimeoutSec"), maxReplacements: tx.maxReplacements, replacementFeeBumpPct: tx.replacementFeeBumpPct, diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index 5769857..2462cbd 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -297,7 +297,7 @@ async function main(): Promise { ); const coordinator = new TxCoordinator( nonce, - { maxCallsPerTx: config.txCoordinator.maxCallsPerTx }, + {}, logger, ); diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 2de7138..dc6b4d7 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -38,11 +38,23 @@ export interface CancelIntent { } /** - * Batch of cancellations and creations the adapter should execute on-chain. - * Cancels always process before creates within each tx. + * Shrink a resting order in place (FIFO preserved). `newSize` is unsigned and + * must be strictly smaller than the resting size; `side` lets adapters apply + * the venue's signed-quantity convention without a book lookup. + */ +export interface ReduceIntent { + orderId: `0x${string}`; + newSize: bigint; + side: Side; +} + +/** + * Batch of cancellations, in-place reduces, and creations the adapter should + * execute on-chain. Order: cancels → reduces → creates (one IM check). */ export interface ExecuteOrdersIntent { cancels: CancelIntent[]; + reduces?: ReduceIntent[]; creates: OrderIntent[]; /** Gas price cap. If not set, the wallet estimates from the network. */ maxFeePerGas?: bigint; @@ -75,6 +87,8 @@ export interface OwnOrderEvent { type: "added" | "updated" | "removed"; order?: OwnOrder; orderId: `0x${string}`; + /** Size-only patch when full `order` is unavailable (e.g. perps OrderUpdated). */ + newSize?: bigint; } /** Position snapshot for a single instrument. */ @@ -304,37 +318,21 @@ export interface InstrumentAdapter { encodeCancel(intent: CancelIntent): `0x${string}`; /** - * Encode venue `updateOrders(cancelIds, creates)` — cancels first, then - * GTC creates, with a single end-of-call collateral check. Either side may - * be empty; callers must skip the encode when both are empty. + * Encode venue `updateOrders(cancelIds, reduces, creates)` — cancels, then + * in-place reduces (FIFO kept), then GTC creates, with a single end-of-call + * collateral check. Any side may be empty; callers must skip the encode when + * all three are empty. */ encodeUpdateOrders( cancels: CancelIntent[], + reduces: ReduceIntent[], creates: OrderIntent[], ): `0x${string}`; /** - * Relative gas weight of placing this create, in "cost units" where one unit - * is roughly the cheapest single call. The shared `TxCoordinator` sums these - * against one per-tx budget when chunking a venue batch, so venues with very - * different per-call gas profiles share one limiter: - * - Perps: 1 per order — a create is one price-level insertion. - * - Futures: `size` (qty) — `createOrder(…, int8 qty)` does one unit of - * work per contract, so gas scales with total qty, not calls. - * Cancels are always weight 1 (the coordinator assumes this). - * A batched `encodeUpdateOrders` call weighs cancels + Σ create weights. - */ - createCallWeight(intent: OrderIntent): number; - - /** - * Execute a batch of order cancellations and creations on-chain. - * - * The adapter owns the full lifecycle: encoding, batching, tx chunking, - * nonce sequencing, and gas optimisation. The caller receives receipts - * for gas tracking and any non-fatal errors from failed tx chunks. + * Execute a batch of order cancellations, reduces, and creations on-chain. * - * Cancels are always processed before creates within each tx to free - * margin before adding new risk. + * Cancels → reduces → creates in one `updateOrders` call (one IM check). */ executeOrders(intent: ExecuteOrdersIntent): Promise; diff --git a/market-maker/src/core/bookTracker.ts b/market-maker/src/core/bookTracker.ts index 23a23aa..129f88c 100644 --- a/market-maker/src/core/bookTracker.ts +++ b/market-maker/src/core/bookTracker.ts @@ -101,9 +101,17 @@ export class BookTracker { case "added": if (evt.order) this.ownOrders.set(evt.orderId, evt.order); break; - case "updated": - if (evt.order) this.ownOrders.set(evt.orderId, evt.order); + case "updated": { + if (evt.order) { + this.ownOrders.set(evt.orderId, evt.order); + } else if (evt.newSize !== undefined) { + const existing = this.ownOrders.get(evt.orderId); + if (existing) { + this.ownOrders.set(evt.orderId, { ...existing, size: evt.newSize }); + } + } break; + } case "removed": this.ownOrders.delete(evt.orderId); break; diff --git a/market-maker/src/core/marketRuntime.ts b/market-maker/src/core/marketRuntime.ts index acaf777..63729c4 100644 --- a/market-maker/src/core/marketRuntime.ts +++ b/market-maker/src/core/marketRuntime.ts @@ -118,6 +118,7 @@ export class MarketRuntime { return { instrument: this.instrument, cancels: planned.cancels.map((o) => ({ orderId: o.orderId })), + reduces: planned.reduces, creates: planned.creates, }; } catch (err) { diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index a0481fe..7fa3c00 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -3,6 +3,7 @@ import type { InstrumentAdapter, OrderIntent, OwnOrder, + ReduceIntent, Side, } from "./adapter.ts"; import type { Quoter } from "./quoter.ts"; @@ -69,14 +70,16 @@ export class OrderExecutor { } /** - * Compute the diff (stale cancels + missing creates) for `desired` without - * submitting anything. Returns `null` when no requote should happen this - * cycle (cooldown, no drift, or gas-spike deferral). The portfolio runner - * feeds the returned intents to the shared `TxCoordinator`, which runs the - * aggregate pre-trade gate — so `plan()` deliberately does NOT call - * `canPlaceOrders` (that would under-count across markets). + * Compute the diff (cancels / in-place reduces / creates) for `desired` + * without submitting. Returns `null` when no requote should happen this + * cycle. Size increases create only the delta; size decreases prefer + * reduce-only amend (FIFO kept) over cancel+recreate. */ - plan(desired: OrderIntent[]): { cancels: OwnOrder[]; creates: OrderIntent[] } | null { + plan(desired: OrderIntent[]): { + cancels: OwnOrder[]; + reduces: ReduceIntent[]; + creates: OrderIntent[]; + } | null { if (!this.shouldRequote(desired)) return null; if (this.gas.isGasSpiking) { @@ -95,13 +98,13 @@ export class OrderExecutor { this.logger.warn({ drift }, "proceeding with requote despite gas spike"); } - const cancels = this.findStaleOrders(desired); - const creates = this.findNewOrders(desired, cancels); - if (cancels.length === 0 && creates.length === 0) { + const { cancels, reduces } = this.findStaleOrders(desired); + const creates = this.findNewOrders(desired, cancels, reduces); + if (cancels.length === 0 && reduces.length === 0 && creates.length === 0) { this.logger.debug("no order changes needed"); return null; } - return { cancels, creates }; + return { cancels, reduces, creates }; } /** @@ -137,27 +140,22 @@ export class OrderExecutor { ); } - if (ordersToCancel.length === 0 && places.length === 0) { + if (ordersToCancel.length === 0 && planned.reduces.length === 0 && places.length === 0) { return; } - // Delegate full lifecycle to the adapter: encoding, batching, tx chunking, - // nonce sequencing, gas optimisation. The executor no longer leaks multicall - // details — it just says what to do and gets back what happened. const result = await this.instrument.executeOrders({ cancels: ordersToCancel.map((o) => ({ orderId: o.orderId })), + reduces: planned.reduces, creates: places, maxFeePerGas: this.gas.cappedGasPrice(), dryRun: this.cfg.dryRun, }); - // Record gas cost from successful tx chunks. for (const receipt of result.receipts) { this.risk.recordGasCost(this.computeTxGasCost(receipt)); } - // Stats count intended orders; partial failure undercounts but metrics - // remain directionally correct (next reconciliation retries the remainder). this.stats.ordersCancelled += ordersToCancel.length; this.stats.ordersPlaced += places.length; @@ -218,8 +216,9 @@ export class OrderExecutor { return true; } - if (this.findStaleOrders(desired).length > 0) { - this.logger.debug("requote triggered: stale orders at wrong prices"); + const stale = this.findStaleOrders(desired); + if (stale.cancels.length > 0 || stale.reduces.length > 0) { + this.logger.debug("requote triggered: stale / excess size at level"); return true; } @@ -258,13 +257,15 @@ export class OrderExecutor { } /** - * Cancel targets for an exact set-diff against `desired`: - * - every resting order at a (side, price) not in the desired grid - * - at desired prices, enough whole orders that aggregated size exceeds - * desired (cancel until remaining ≤ desired; deficits are topped up - * by `findNewOrders`) + * Cancel / reduce targets for an exact set-diff against `desired`: + * - every resting order at a (side, price) not in the desired grid → cancel + * - at desired prices with excess size: reduce the trailing order in place + * when possible (FIFO kept); cancel whole trailing orders otherwise */ - private findStaleOrders(desired: OrderIntent[]): OwnOrder[] { + private findStaleOrders(desired: OrderIntent[]): { + cancels: OwnOrder[]; + reduces: ReduceIntent[]; + } { const desiredSize = new Map(); for (const i of desired) { const k = keyOf(i.side, i.price); @@ -279,33 +280,48 @@ export class OrderExecutor { else byKey.set(k, [order]); } - const stale: OwnOrder[] = []; + const cancels: OwnOrder[] = []; + const reduces: ReduceIntent[] = []; for (const [k, orders] of byKey) { const want = desiredSize.get(k); if (want === undefined) { - for (const o of orders) stale.push(o); + for (const o of orders) cancels.push(o); continue; } let have = 0n; for (const o of orders) have += o.size; if (have <= want) continue; - // Drop whole orders until remaining size fits; prefer cancelling the - // trailing entries so FIFO priority of earlier quotes is preserved. + // Trim from the trailing order so earlier FIFO priority is preserved. let excess = have - want; for (let i = orders.length - 1; i >= 0 && excess > 0n; i--) { - stale.push(orders[i]); - excess -= orders[i].size; + const o = orders[i]; + if (o.size <= excess) { + cancels.push(o); + excess -= o.size; + } else { + reduces.push({ + orderId: o.orderId, + newSize: o.size - excess, + side: o.side, + }); + excess = 0n; + } } } - return stale; + return { cancels, reduces }; } /** * New orders = desired levels missing size at exactly the desired price, - * after subtracting any orders already selected for cancel in this plan. + * after applying cancels/reduces from this plan. Size increases only create + * the delta — resting orders at that level are never cancelled for a top-up. */ - private findNewOrders(desired: OrderIntent[], cancels: OwnOrder[]): OrderIntent[] { - const existing = this.aggregateOwnSizeByPriceSide(cancels); + private findNewOrders( + desired: OrderIntent[], + cancels: OwnOrder[], + reduces: ReduceIntent[], + ): OrderIntent[] { + const existing = this.aggregateOwnSizeByPriceSide(cancels, reduces); const out: OrderIntent[] = []; for (const i of desired) { const have = existing.get(keyOf(i.side, i.price)) ?? 0n; @@ -329,13 +345,18 @@ export class OrderExecutor { return false; } - private aggregateOwnSizeByPriceSide(cancels: OwnOrder[] = []): Map { + private aggregateOwnSizeByPriceSide( + cancels: OwnOrder[] = [], + reduces: ReduceIntent[] = [], + ): Map { const cancelled = new Set(cancels.map((c) => c.orderId)); + const reduced = new Map(reduces.map((r) => [r.orderId, r.newSize])); const m = new Map(); for (const o of this.book.ownOrders.values()) { if (cancelled.has(o.orderId)) continue; + const size = reduced.get(o.orderId) ?? o.size; const k = keyOf(o.side, o.price); - m.set(k, (m.get(k) ?? 0n) + o.size); + m.set(k, (m.get(k) ?? 0n) + size); } return m; } diff --git a/market-maker/src/core/portfolioRunner.ts b/market-maker/src/core/portfolioRunner.ts index 63fc81c..fde7d2e 100644 --- a/market-maker/src/core/portfolioRunner.ts +++ b/market-maker/src/core/portfolioRunner.ts @@ -154,7 +154,9 @@ export async function runPortfolioTick( const p = m.plan(now); if (p) intents.push(state.pauseNew ? { ...p, creates: [] } : p); } - const active = intents.filter((i) => i.cancels.length > 0 || i.creates.length > 0); + const active = intents.filter( + (i) => i.cancels.length > 0 || (i.reduces?.length ?? 0) > 0 || i.creates.length > 0, + ); if (active.length > 0) { const res = await coordinator.submit(active, { diff --git a/market-maker/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts index 34151dd..267eeb6 100644 --- a/market-maker/src/core/txCoordinator.ts +++ b/market-maker/src/core/txCoordinator.ts @@ -3,6 +3,7 @@ import type { CancelIntent, InstrumentAdapter, OrderIntent, + ReduceIntent, VenueAdapter, } from "./adapter.ts"; import type { NonceManager, TxOutcome } from "./nonceManager.ts"; @@ -11,17 +12,11 @@ import type { NonceManager, TxOutcome } from "./nonceManager.ts"; export interface MarketIntents { instrument: InstrumentAdapter; cancels: CancelIntent[]; + reduces: ReduceIntent[]; creates: OrderIntent[]; } -export interface TxCoordinatorConfig { - /** - * Max cost units per on-chain tx before splitting a venue's work across - * sequential `updateOrders` txs. One unit is the cheapest single call (see - * `InstrumentAdapter.createCallWeight`). Cancels weigh 1 each. Default 50. - */ - maxCallsPerTx?: number; -} +export type TxCoordinatorConfig = Record; export interface SubmitOptions { maxFeePerGas: bigint; @@ -40,6 +35,7 @@ export interface SubmitResult { errors: Error[]; ordersPlaced: number; ordersCancelled: number; + ordersReduced: number; /** True if the aggregate gate denied placements (creates were dropped). */ gateDenied: boolean; } @@ -50,19 +46,17 @@ export interface SubmitResult { * Responsibilities: * 1. Aggregate pre-trade gate over ALL markets' creates (one canPlaceOrder). * 2. Group intents by venue — all futures expiries merge into one - * `updateOrders(cancels, creates)` (cancels first, one IM check). - * 3. Submit each venue via `sendCall` (no multicall). Oversized work splits - * into sequential txs: cancel-only chunks first, then create-only chunks. + * `updateOrders(cancels, reduces, creates)` (cancels → reduces → creates, + * one IM check). + * 3. Submit each venue via a single `sendCall` (no multicall, no chunking). * 4. Isolate venue failures: a revert on one venue never blocks the other. */ export class TxCoordinator { private readonly nonce: NonceManager; - private readonly maxCallsPerTx: number; private readonly logger: pino.Logger; - constructor(nonce: NonceManager, cfg: TxCoordinatorConfig, logger: pino.Logger) { + constructor(nonce: NonceManager, _cfg: TxCoordinatorConfig, logger: pino.Logger) { this.nonce = nonce; - this.maxCallsPerTx = cfg.maxCallsPerTx ?? 50; this.logger = logger.child({ component: "tx-coordinator" }); } @@ -72,6 +66,7 @@ export class TxCoordinator { errors: [], ordersPlaced: 0, ordersCancelled: 0, + ordersReduced: 0, gateDenied: false, }; @@ -89,7 +84,7 @@ export class TxCoordinator { result.gateDenied = true; this.logger.warn( { additionalIM: additionalIM.toString(), wouldPlace: totalCreates }, - "aggregate canPlaceOrder denied; cancelling stale only", + "aggregate canPlaceOrder denied; cancelling/reducing stale only", ); } } @@ -109,9 +104,11 @@ export class TxCoordinator { if (!encoder) continue; const cancels: CancelIntent[] = []; + const reduces: ReduceIntent[] = []; const creates: OrderIntent[] = []; for (const m of markets) { cancels.push(...m.cancels); + reduces.push(...(m.reduces ?? [])); if (!allowCreates) continue; const expiry = instrumentExpirationAt(m.instrument); for (const c of m.creates) { @@ -119,36 +116,35 @@ export class TxCoordinator { } } - if (cancels.length === 0 && creates.length === 0) continue; + if (cancels.length === 0 && reduces.length === 0 && creates.length === 0) continue; - const payloads = this.encodeVenueUpdateOrders(encoder, cancels, creates); + const data = encoder.encodeUpdateOrders(cancels, reduces, creates); if (opts.dryRun) { this.logger.info( { venue: venue.kind, cancels: cancels.length, + reduces: reduces.length, creates: creates.length, - txs: payloads.length, }, "DRY RUN: would submit venue updateOrders", ); result.ordersCancelled += cancels.length; + result.ordersReduced += reduces.length; result.ordersPlaced += creates.length; continue; } try { - for (let i = 0; i < payloads.length; i++) { - const data = payloads[i]; - const outcome = await this.nonce.submit( - ({ nonce, maxFeePerGas }) => - venue.sendCall(data, { maxFeePerGas, nonce }), - { maxFeePerGas: opts.maxFeePerGas, label: `${venue.kind}#${i}` }, - ); - result.receipts.push(outcome); - } + const outcome = await this.nonce.submit( + ({ nonce, maxFeePerGas }) => + venue.sendCall(data, { maxFeePerGas, nonce }), + { maxFeePerGas: opts.maxFeePerGas, label: venue.kind }, + ); + result.receipts.push(outcome); result.ordersCancelled += cancels.length; + result.ordersReduced += reduces.length; result.ordersPlaced += creates.length; } catch (err) { const wrapped = err instanceof Error ? err : new Error(String(err)); @@ -162,59 +158,9 @@ export class TxCoordinator { return result; } - - /** - * Encode one or more `updateOrders` payloads for a venue. Prefer a single - * call (all cancels then all creates, one IM check). When over budget, split - * into cancel-only chunks followed by create-only chunks so later creates - * still see earlier cancels' freed margin across sequential txs. - */ - private encodeVenueUpdateOrders( - encoder: InstrumentAdapter, - cancels: CancelIntent[], - creates: OrderIntent[], - ): `0x${string}`[] { - let weight = cancels.length; - for (const c of creates) weight += Math.max(1, encoder.createCallWeight(c)); - - if (weight <= this.maxCallsPerTx) { - return [encoder.encodeUpdateOrders(cancels, creates)]; - } - - const out: `0x${string}`[] = []; - for (const slice of chunkArray(cancels, this.maxCallsPerTx)) { - out.push(encoder.encodeUpdateOrders(slice, [])); - } - - let createBuf: OrderIntent[] = []; - let createWeight = 0; - for (const c of creates) { - const w = Math.max(1, encoder.createCallWeight(c)); - if (createBuf.length > 0 && createWeight + w > this.maxCallsPerTx) { - out.push(encoder.encodeUpdateOrders([], createBuf)); - createBuf = []; - createWeight = 0; - } - createBuf.push(c); - createWeight += w; - } - if (createBuf.length > 0) { - out.push(encoder.encodeUpdateOrders([], createBuf)); - } - return out; - } } function instrumentExpirationAt(instrument: InstrumentAdapter): bigint | undefined { const expiry = (instrument as { expirationAt?: unknown }).expirationAt; return typeof expiry === "bigint" ? expiry : undefined; } - -function chunkArray(items: T[], size: number): T[][] { - if (items.length === 0) return []; - const out: T[][] = []; - for (let i = 0; i < items.length; i += size) { - out.push(items.slice(i, i + size)); - } - return out; -} diff --git a/market-maker/tests/apps/portfolio/config.test.ts b/market-maker/tests/apps/portfolio/config.test.ts index b7c487b..ad721c0 100644 --- a/market-maker/tests/apps/portfolio/config.test.ts +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -114,7 +114,6 @@ describe("loadPortfolioConfig", () => { it("applies txCoordinator and circuitBreaker defaults", () => { const path = writeTmp(tmpDir, "test.yml", VALID_YAML); const cfg = loadPortfolioConfig({ path }); - assert.equal(cfg.txCoordinator.maxCallsPerTx, 100); assert.equal(cfg.txCoordinator.confirmationTimeoutMs, 60_000); assert.equal(cfg.circuitBreaker.quarantineThreshold, 3); assert.equal(cfg.rollCheckIntervalMs, 300_000); diff --git a/market-maker/tests/core/marketRuntime.test.ts b/market-maker/tests/core/marketRuntime.test.ts index d623f08..9cf1822 100644 --- a/market-maker/tests/core/marketRuntime.test.ts +++ b/market-maker/tests/core/marketRuntime.test.ts @@ -18,7 +18,11 @@ function makeLogger(): never { interface Knobs { refreshFails: boolean; - plan: { cancels: { orderId: `0x${string}` }[]; creates: unknown[] } | null; + plan: { + cancels: { orderId: `0x${string}` }[]; + reduces: unknown[]; + creates: unknown[]; + } | null; } function makeDeps(knobs: Knobs): { deps: MarketRuntimeDeps; knobs: Knobs; recorded: number[] } { @@ -80,7 +84,10 @@ describe("MarketRuntime", () => { }); it("quarantines after repeated update failures and skips planning", async () => { - const { deps, knobs } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + const { deps, knobs } = makeDeps({ + refreshFails: false, + plan: { cancels: [], reduces: [], creates: [] }, + }); const m = new MarketRuntime(deps); await m.start(); @@ -113,7 +120,11 @@ describe("MarketRuntime", () => { it("plan() emits MarketIntents mapping cancels to orderIds", async () => { const { deps } = makeDeps({ refreshFails: false, - plan: { cancels: [{ orderId: "0xabc" }], creates: [{ side: "buy", price: 1n, size: 1n }] }, + plan: { + cancels: [{ orderId: "0xabc" }], + reduces: [], + creates: [{ side: "buy", price: 1n, size: 1n }], + }, }); const m = new MarketRuntime(deps); await m.start(); @@ -135,7 +146,10 @@ describe("MarketRuntime", () => { }); it("lazily initializes on the first update() for a market that never started", async () => { - const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + const { deps } = makeDeps({ + refreshFails: false, + plan: { cancels: [], reduces: [], creates: [] }, + }); let bootstrapped = 0; (deps.instrument as unknown as { ownOrders: { bootstrap: () => Promise } }).ownOrders = { bootstrap: async () => { @@ -181,7 +195,10 @@ describe("MarketRuntime", () => { }); it("plan() returns null and records an error when quoting throws", async () => { - const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + const { deps } = makeDeps({ + refreshFails: false, + plan: { cancels: [], reduces: [], creates: [] }, + }); (deps.quoter as unknown as { computeQuotes: () => never }).computeQuotes = () => { throw new Error("quote boom"); }; diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index 787378a..b6b5cf0 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -38,7 +38,6 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { executeOrders: async () => ({ receipts: [], errors: [] }), estimateOrderMargin: () => 0n, estimateCreateGas: async () => 0n, - createCallWeight: () => 1, }; } diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 395108f..5b3b5f9 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -259,6 +259,8 @@ describe("OrderExecutor quantity deficit", () => { executor.recordRequote(0, 0); const planned = executor.plan([desiredBuy(95_000_000n, 3n), desiredSell(96_000_000n, 3n)]); assert.ok(planned, "requote triggered by quantity deficit"); + assert.equal(planned.cancels.length, 0, "size increase must not cancel resting"); + assert.equal(planned.reduces.length, 0); assert.equal(planned.creates.length, 1, "tops up the missing buy size"); assert.equal(planned.creates[0].size, 1n); }); @@ -375,19 +377,32 @@ describe("OrderExecutor stale detection", () => { assert.ok(!cancelled.has(makeOrderId(3)), "on-grid ask kept"); }); - it("cancels excess size at a desired price", () => { + it("cancels a whole trailing order when excess covers it", () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); - seedOrder(deps.book, 2, "buy", 95_000_000n, 2n); // aggregate 4 > desired 3 + seedOrder(deps.book, 2, "buy", 95_000_000n, 2n); // aggregate 4 > desired 2 - const planned = executor.plan([desiredBuy(95_000_000n, 3n)]); + const planned = executor.plan([desiredBuy(95_000_000n, 2n)]); assert.ok(planned); - assert.equal(planned.cancels.length, 1, "one whole order dropped for excess"); + assert.equal(planned.cancels.length, 1, "trailing whole order cancelled"); assert.equal(planned.cancels[0].orderId, makeOrderId(2)); - // After cancelling size 2, remaining is 2 < 3 → top up 1. - assert.equal(planned.creates.length, 1); - assert.equal(planned.creates[0].size, 1n); + assert.equal(planned.reduces.length, 0); + assert.equal(planned.creates.length, 0); + }); + + it("reduces trailing order in place when excess is partial", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 95_000_000n, 4n); + + const planned = executor.plan([desiredBuy(95_000_000n, 3n)]); + assert.ok(planned); + assert.equal(planned.cancels.length, 0, "FIFO kept via reduce, not cancel"); + assert.equal(planned.reduces.length, 1); + assert.equal(planned.reduces[0].orderId, makeOrderId(1)); + assert.equal(planned.reduces[0].newSize, 3n); + assert.equal(planned.creates.length, 0); }); }); diff --git a/market-maker/tests/core/portfolioRunner.test.ts b/market-maker/tests/core/portfolioRunner.test.ts index cd1cba6..d90a719 100644 --- a/market-maker/tests/core/portfolioRunner.test.ts +++ b/market-maker/tests/core/portfolioRunner.test.ts @@ -67,6 +67,7 @@ function makeMarket( cancels: Array.from({ length: planResult.cancels }, (_, i) => ({ orderId: `0x${i.toString(16).padStart(64, "0")}` as `0x${string}`, })), + reduces: [], creates: Array.from({ length: planResult.creates }, () => ({ side: "buy" as const, price: 1n, @@ -129,6 +130,7 @@ function makeDeps(over: DepOverrides = {}): Tracked { errors: [], ordersPlaced: all.reduce((n, i) => n + i.creates.length, 0), ordersCancelled: all.reduce((n, i) => n + i.cancels.length, 0), + ordersReduced: all.reduce((n, i) => n + (i.reduces?.length ?? 0), 0), gateDenied: false, }); @@ -305,6 +307,7 @@ describe("runPortfolioTick", () => { errors: [new Error("venue revert")], ordersPlaced: 0, ordersCancelled: 0, + ordersReduced: 0, gateDenied: false, }), }); diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts index 2837528..f295c83 100644 --- a/market-maker/tests/core/txCoordinator.test.ts +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { TxCoordinator, type MarketIntents } from "../../src/core/txCoordinator.ts"; import type { NonceManager } from "../../src/core/nonceManager.ts"; -import type { InstrumentAdapter, VenueAdapter } from "../../src/core/adapter.ts"; +import type { InstrumentAdapter, ReduceIntent, VenueAdapter } from "../../src/core/adapter.ts"; const noop = () => {}; function makeLogger(): never { @@ -51,6 +51,7 @@ function makeMarket( cancels: string[], creates: { price: bigint; im: bigint; size?: bigint }[], expirationAt?: bigint, + reduces: ReduceIntent[] = [], ): MarketIntents { const instrument = { id, @@ -60,9 +61,13 @@ function makeMarket( encodeCreate: (o: { price: bigint }) => `0xO${o.price.toString()}` as `0x${string}`, encodeUpdateOrders: ( cancelIntents: { orderId: `0x${string}` }[], + reduceIntents: ReduceIntent[], orders: { price: bigint; expirationAt?: bigint }[], ) => { const cancelPart = cancelIntents.map((c) => c.orderId.slice(2)).join("+"); + const reducePart = reduceIntents + .map((r) => `${r.orderId.slice(2)}=${r.newSize}`) + .join("+"); const createPart = orders .map((o) => o.expirationAt !== undefined @@ -70,15 +75,15 @@ function makeMarket( : o.price.toString(), ) .join(","); - return `0xU${cancelPart}>${createPart}` as `0x${string}`; + return `0xU${cancelPart}|${reducePart}>${createPart}` as `0x${string}`; }, estimateOrderMargin: (o: { price: bigint }) => creates.find((c) => c.price === o.price)?.im ?? 0n, - createCallWeight: (o: { size: bigint }) => Number(o.size), } as unknown as InstrumentAdapter; return { instrument, cancels: cancels.map((o) => ({ orderId: o as `0x${string}` })), + reduces, creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: c.size ?? 1n })), }; } @@ -118,7 +123,7 @@ describe("TxCoordinator", () => { assert.equal(res.ordersPlaced, 0); assert.equal(res.ordersCancelled, 1); assert.equal(submitCount(), 1); - assert.deepEqual(calls, ["0xUdead>"]); // updateOrders with cancels only + assert.deepEqual(calls, ["0xUdead|>"]); // updateOrders with cancels only }); it("merges all same-venue expiries into one updateOrders call", async () => { @@ -131,7 +136,27 @@ describe("TxCoordinator", () => { ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); assert.equal(submitCount(), 1); - assert.deepEqual(calls, ["0xUa+b>1@100,2@200"]); + assert.deepEqual(calls, ["0xUa+b|>1@100,2@200"]); + }); + + it("includes reduces in the same venue updateOrders call", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, calls } = makeVenue("futures"); + const reduces: ReduceIntent[] = [ + { orderId: "0xabc", newSize: 2n, side: "buy" }, + ]; + const markets = [ + makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n, size: 10n }], 100n, reduces), + ]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async () => true, + }); + assert.equal(submitCount(), 1); + assert.deepEqual(calls, ["0xUa|abc=2>1@100"]); + assert.equal(res.ordersReduced, 1); }); it("isolates venue failures: one venue's revert doesn't block the other", async () => { @@ -153,36 +178,22 @@ describe("TxCoordinator", () => { assert.equal(futures.calls.length, 1); }); - it("splits an over-budget venue into cancel-then-create updateOrders txs", async () => { + it("keeps a large cancel+create batch in one updateOrders (no weight splitting)", async () => { const { nm, submitCount } = makeNonce(); - const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); + const coord = new TxCoordinator(nm, {}, makeLogger()); const { venue, calls } = makeVenue("futures"); - // 3 cancels + create weight 3 → over budget 2 → cancel chunk(s) then create chunk. const markets = [ makeMarket( venue, "f1", ["0xa", "0xb", "0xc"], - [{ price: 1n, im: 0n, size: 3n }], + [{ price: 1n, im: 0n, size: 50n }], 100n, ), ]; await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); - assert.equal(submitCount(), 3); - assert.deepEqual(calls, ["0xUa+b>", "0xUc>", "0xU>1@100"]); - }); - - it("keeps under-budget cancel+create in one updateOrders across expiries", async () => { - const { nm, submitCount } = makeNonce(); - const coord = new TxCoordinator(nm, { maxCallsPerTx: 20 }, makeLogger()); - const { venue, calls } = makeVenue("futures"); - const markets = [ - makeMarket(venue, "f1", ["0xz"], [{ price: 1n, im: 0n, size: 5n }], 100n), - makeMarket(venue, "f2", [], [{ price: 2n, im: 0n, size: 1n }], 200n), - ]; - await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); assert.equal(submitCount(), 1); - assert.deepEqual(calls, ["0xUz>1@100,2@200"]); + assert.deepEqual(calls, ["0xUa+b+c|>1@100"]); }); it("dry run submits nothing but reports intended counts", async () => { From 4900db6635d473bb28668dd86d5684278be1af17 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:49:06 +0200 Subject: [PATCH 111/180] chore: refresh keeper/MM lockfiles for reduce-order deps --- keeper/pnpm-lock.yaml | 20 ++-- market-maker/pnpm-lock.yaml | 179 ++++++++++++++++++++++++------------ 2 files changed, 132 insertions(+), 67 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 1b20ff6..a01e990 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -19,11 +19,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -209,8 +209,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/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-5mRqh023RoayXNLVO58BjqMkqs6zAGZaGCEb5PBEg9RLZb5PDOi/dE86PO/MhjVK2Lpxb5tS07fdT9dcShxjJQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-s9voZzUVZLcypnh3Dk//u4Ifo5ujsGvsLmP6vKcAjOxaOVOZilkfiQHCtXxowAupkfdbCSRKxrXJPVaUsrv0RQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} version: 0.0.0 end-of-stream@1.4.5: @@ -225,8 +225,8 @@ 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/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-mW4lo28Ct8lTkAf+UCMqEy9RfLr2Sw9V32vERidrG6ym/FxAzys95cmq+ydJ5lhN5dqmUStRv854aKc6u1UUOA==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-Ew90WlZvizYDzSjPKoOqWwdhVcTKeXhH8qz5nPLMLnQEzM3LtGNP4shHXGb3uR55V224KESmZhxZAc306c8Ulg==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} version: 0.0.0 help-me@5.0.0: @@ -446,7 +446,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -458,7 +458,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 2583ed0..2820eaa 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -27,14 +27,14 @@ importers: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#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 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#7df057016ed148058ba2916c441e951a7b56a08c&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#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)) + specifier: github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -509,10 +509,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} @@ -697,23 +693,21 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@safe-global/api-kit@3.0.2': - resolution: {integrity: sha512-+DQm88HRe4bDOHsvi/9HKT3foXe1aS1iOKyu2yAqnJOAXURtdjhYmsMHzsseA/3qzlepC9SdC/ZBwr2Do0vzgQ==} + '@safe-global/api-kit@5.0.1': + resolution: {integrity: sha512-nbkSjrRTZh+LlD7e0A4fujKgLZnU5262e1iYTWsGx4ft6oo5kTAxc5pY0icls5PjmZI0UkMhYIv5izeBP7bcWg==} - '@safe-global/protocol-kit@6.1.2': - resolution: {integrity: sha512-cTpPdUAS2AMfGCkD1T601rQNjT0rtMQLA2TH7L/C+iFPAC6WrrDFop2B9lzeHjczlnVzrRpfFe4cL1bLrJ9NZw==} + '@safe-global/protocol-kit@8.0.4': + resolution: {integrity: sha512-j6PXStIUYEGATdyE8CWMzE1uVAkDBta3o+3atwPTKVE5OIWuqk+QQ7s4kbm1x42yaMf9e5e7LxIJ8E8WmI3i0g==} - '@safe-global/safe-deployments@1.37.56': - resolution: {integrity: sha512-HF3ETre/KSP3nCOZ72XEbq5U56gOGgYLJ22LxOAnR8+YzMjzZ8cpnHpx5Z31Zt1xkUTGSCMi9XF950lJt6WbsQ==} + '@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@2.2.25': - resolution: {integrity: sha512-KjgenKhBRyFHEfo8xlBgNzKAy25vrmGyCGZwTjIuA81yOSRJRe85GE5Yfg/FBKeeyHqR2dD1WPZr6c2Uqd6C/g==} + '@safe-global/safe-modules-deployments@3.0.8': + resolution: {integrity: sha512-RKct6dNFg4KbbpXchyosHmIygy58wLe+1WIATQ2VILGpcivBDcZdyvSl64BoocXCL2CroCBMJMzy0mW4iwZ0Zg==} - '@safe-global/types-kit@2.0.1': - resolution: {integrity: sha512-4xKjTBlyFSIKziqvjrGMBAgs7Z2+s/5A2wjAXg2gBA1BuvV6w1THk1Y/WMZg8+6/PlRaVMVo4LoSNMRSsZZhjw==} - - '@safe-global/types-kit@3.1.0': - resolution: {integrity: sha512-uI6lFV8wOji4rb7juu0/LRMND0rq2NWaqH4zFzE2FUjO8lTvbCT+/5W/+flUUfkcaqyLDlJ1OPjasa2bEakrbw==} + '@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==} @@ -1392,8 +1386,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c} + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} version: 1.0.0 engines: {node: 24.x} @@ -1606,8 +1600,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} version: 1.0.0 engines: {node: 24.x} @@ -1973,6 +1967,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: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2032,6 +2030,14 @@ packages: typescript: optional: true + ox@0.14.31: + resolution: {integrity: sha512-WqtI37YEJCV6wkLw877FPrwWHwXEFrVZLecqBqQQjUmFhATjd+upfaKpWHXrhKaRUPMU6LH8T1lydHBda5ww5A==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -2297,6 +2303,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -2535,6 +2546,14 @@ packages: typescript: optional: true + viem@2.55.5: + resolution: {integrity: sha512-2GaTBslLhbP1xUbFoSkFbzKye5W76ixPlaqPg96HFyc40R76t95dqITYoXXr/xP9bhmDLYKxLODTLoiWWjpViQ==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -2604,6 +2623,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3572,8 +3603,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': @@ -3783,12 +3812,12 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@safe-global/api-kit@3.0.2(typescript@5.9.3)': + '@safe-global/api-kit@5.0.1(typescript@5.9.3)': dependencies: - '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) - '@safe-global/types-kit': 2.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) node-fetch: 2.7.0 - viem: 2.48.4(typescript@5.9.3) + viem: 2.55.5(typescript@5.9.3) transitivePeerDependencies: - bufferutil - encoding @@ -3796,14 +3825,14 @@ snapshots: - utf-8-validate - zod - '@safe-global/protocol-kit@6.1.2(typescript@5.9.3)': + '@safe-global/protocol-kit@8.0.4(typescript@5.9.3)': dependencies: - '@safe-global/safe-deployments': 1.37.56 - '@safe-global/safe-modules-deployments': 2.2.25 - '@safe-global/types-kit': 3.1.0(typescript@5.9.3) + '@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.7.4 - viem: 2.48.4(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 @@ -3813,20 +3842,13 @@ snapshots: - utf-8-validate - zod - '@safe-global/safe-deployments@1.37.56': + '@safe-global/safe-deployments@1.37.60': dependencies: - semver: 7.7.4 + semver: 7.8.5 - '@safe-global/safe-modules-deployments@2.2.25': {} + '@safe-global/safe-modules-deployments@3.0.8': {} - '@safe-global/types-kit@2.0.1(typescript@5.9.3)': - dependencies: - abitype: 1.2.3(typescript@5.9.3) - transitivePeerDependencies: - - typescript - - zod - - '@safe-global/types-kit@3.1.0(typescript@5.9.3)': + '@safe-global/types-kit@4.0.1(typescript@5.9.3)': dependencies: abitype: 1.2.3(typescript@5.9.3) transitivePeerDependencies: @@ -4660,16 +4682,17 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/7df057016ed148058ba2916c441e951a7b56a08c#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)): + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#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/a7f89b8fd167655d1157568230bc6779ce37d512 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' @@ -4943,20 +4966,20 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#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': 3.0.2(typescript@5.9.3) - '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) - '@safe-global/types-kit': 2.0.1(typescript@5.9.3) + '@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: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - 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 - viem: 2.48.4(typescript@5.9.3) + viem: 2.55.5(typescript@5.9.3) transitivePeerDependencies: - '@types/node' - bufferutil @@ -5221,6 +5244,10 @@ snapshots: dependencies: ws: 8.18.3 + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + joycon@3.1.1: {} js-cookie@2.2.1: {} @@ -5373,6 +5400,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: {} @@ -5421,6 +5450,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.31(typescript@5.9.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -5681,6 +5725,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -5894,6 +5940,23 @@ snapshots: - utf-8-validate - zod + viem@2.55.5(typescript@5.9.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + isows: 1.0.7(ws@8.21.0) + ox: 0.14.31(typescript@5.9.3) + ws: 8.21.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -5939,6 +6002,8 @@ snapshots: ws@8.18.3: {} + ws@8.21.0: {} + y18n@5.0.8: {} yaml@1.10.3: {} From 2f96ae6b09fd55cf58038d7c7047267b41b5475e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:55:09 +0200 Subject: [PATCH 112/180] fix(keeper): restore ABI path in lockfile and CI errors pnpm install dropped path:/contracts/abi from git dep resolutions, so cold CI unpacked the full futures repo and test -f failed silently under set -e. --- .github/workflows/keeper-test.yml | 28 ++++++++++++++++++++++------ keeper/pnpm-lock.yaml | 4 ++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 5279eb5..c7ae733 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -60,8 +60,24 @@ jobs: run: | set -euo pipefail pkg="node_modules/futures-marketplace-abi" - test -f "$pkg/Futures.ts" - test -f "$pkg/package.json" + if [[ ! -e "$pkg" ]]; then + echo "::error::$pkg is missing after pnpm install" + ls -la node_modules | head -40 + 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 + 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 @@ -94,16 +110,16 @@ jobs: uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - # updateOrders + liquidateOrders(user, ids) (PR #78). - ref: 7df057016ed148058ba2916c441e951a7b56a08c + # reduceOrderSize + three-arg updateOrders (PR #80). + ref: 9b81be323ec1bbea0891ce6f87842b92d2fb8963 path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - # updateOrders + liquidateOrders(user, ids) (PR #223). - ref: ac6b2d8fbd8f9fd5a47468eec7ced6cdd9ca6a42 + # reduceOrderSize + three-arg updateOrders (PR #227). + ref: baec46ef8f428d7be4c397dc2d03721805c1b46f path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index a01e990..4624596 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -210,7 +210,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-s9voZzUVZLcypnh3Dk//u4Ifo5ujsGvsLmP6vKcAjOxaOVOZilkfiQHCtXxowAupkfdbCSRKxrXJPVaUsrv0RQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-s9voZzUVZLcypnh3Dk//u4Ifo5ujsGvsLmP6vKcAjOxaOVOZilkfiQHCtXxowAupkfdbCSRKxrXJPVaUsrv0RQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} version: 0.0.0 end-of-stream@1.4.5: @@ -226,7 +226,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-Ew90WlZvizYDzSjPKoOqWwdhVcTKeXhH8qz5nPLMLnQEzM3LtGNP4shHXGb3uR55V224KESmZhxZAc306c8Ulg==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-Ew90WlZvizYDzSjPKoOqWwdhVcTKeXhH8qz5nPLMLnQEzM3LtGNP4shHXGb3uR55V224KESmZhxZAc306c8Ulg==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} version: 0.0.0 help-me@5.0.0: From 145a2b758f29409c8b1de4e9ecca66fd4c014cc6 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 16:58:18 +0200 Subject: [PATCH 113/180] chore: bump contract git deps to regenerated ABI commits --- .github/workflows/keeper-test.yml | 4 +-- keeper/package.json | 4 +-- keeper/pnpm-lock.yaml | 20 +++++------ market-maker/package.json | 4 +-- market-maker/pnpm-lock.yaml | 55 ++++++++++++++----------------- 5 files changed, 40 insertions(+), 47 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index c7ae733..268323e 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -111,7 +111,7 @@ jobs: with: repository: Lumerin-protocol/derivatives-marketplace # reduceOrderSize + three-arg updateOrders (PR #80). - ref: 9b81be323ec1bbea0891ce6f87842b92d2fb8963 + ref: 85ce3c88afe23d2c4b6e451f7de6447cac0eda6d path: perps - name: Checkout futures-marketplace @@ -119,7 +119,7 @@ jobs: with: repository: Lumerin-protocol/futures-marketplace # reduceOrderSize + three-arg updateOrders (PR #227). - ref: baec46ef8f428d7be4c397dc2d03721805c1b46f + ref: a2115badfbd78206c172b0bd5cb47d697985bca9 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/keeper/package.json b/keeper/package.json index 9b2e425..3cf6f70 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -23,8 +23,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 4624596..44f2526 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -19,11 +19,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -209,8 +209,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/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-s9voZzUVZLcypnh3Dk//u4Ifo5ujsGvsLmP6vKcAjOxaOVOZilkfiQHCtXxowAupkfdbCSRKxrXJPVaUsrv0RQ==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-S71kDrzEuZGW+WlkvdKVp/SHe0f5bpA1HnSJnl2ymC1lcr+T07njTa+ZhG5V49qwyOJlCTADED0zvar8E6o+Ew==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} version: 0.0.0 end-of-stream@1.4.5: @@ -225,8 +225,8 @@ 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/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-Ew90WlZvizYDzSjPKoOqWwdhVcTKeXhH8qz5nPLMLnQEzM3LtGNP4shHXGb3uR55V224KESmZhxZAc306c8Ulg==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-DH1KCNN4FM6f+Et4yGxiHfv+GXKShVNxvBmGPvSWE5y/D046FDtzCdcs9ptaeCTgjWPKiHcB6BmB6wp2NGp+HQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} version: 0.0.0 help-me@5.0.0: @@ -446,7 +446,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -458,7 +458,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/market-maker/package.json b/market-maker/package.json index eb810a0..986427e 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -39,9 +39,9 @@ "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#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 2820eaa..1f6d4f0 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -27,14 +27,14 @@ importers: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#baec46ef8f428d7be4c397dc2d03721805c1b46f&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#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 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#9b81be323ec1bbea0891ce6f87842b92d2fb8963&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#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)) + specifier: github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -509,6 +509,10 @@ 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} @@ -1386,8 +1390,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963} + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} version: 1.0.0 engines: {node: 24.x} @@ -1600,8 +1604,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} version: 1.0.0 engines: {node: 24.x} @@ -1967,10 +1971,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2298,11 +2298,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3111,7 +3106,7 @@ snapshots: '@openzeppelin/contracts-upgradeable': 4.9.6 '@scroll-tech/contracts': 2.0.0 '@zksync/contracts': era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9 - semver: 7.7.4 + semver: 7.8.5 transitivePeerDependencies: - '@types/node' - bufferutil @@ -3134,7 +3129,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 '@changesets/assemble-release-plan@6.0.10': dependencies: @@ -3143,7 +3138,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.4 + semver: 7.8.5 '@changesets/changelog-git@0.2.1': dependencies: @@ -3174,7 +3169,7 @@ snapshots: package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: @@ -3200,7 +3195,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.4 + semver: 7.8.5 '@changesets/get-github-info@0.6.0': dependencies: @@ -3603,6 +3598,8 @@ 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': @@ -4682,17 +4679,17 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/9b81be323ec1bbea0891ce6f87842b92d2fb8963#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)): + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/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/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4966,7 +4963,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/baec46ef8f428d7be4c397dc2d03721805c1b46f#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#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 @@ -4977,7 +4974,7 @@ snapshots: '@safe-global/types-kit': 4.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + 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: @@ -5400,8 +5397,6 @@ 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: {} @@ -5723,8 +5718,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.5: {} serialize-javascript@6.0.2: From 74c93e17d6e93704481adfd6480a4014a781920e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 18:37:16 +0200 Subject: [PATCH 114/180] feat(mm): cut requote churn with band and size allowances Restore limit-LOB band keep/cancel with configurable USD price and size allowances, drop the redundant mid-drift requote gate, deepen books to 10 levels, and raise collateral ceilings to fund the wider ladder. Co-authored-by: Cursor --- market-maker/README.md | 13 +- market-maker/configs/futures.dev.yml | 7 +- market-maker/configs/futures.local.yml | 5 +- market-maker/configs/futures.prd.yml | 7 +- market-maker/configs/futures.stg.yml | 7 +- market-maker/configs/perps.dev.yml | 7 +- market-maker/configs/perps.local.yml | 5 +- market-maker/configs/perps.prd.yml | 7 +- market-maker/configs/perps.stg.yml | 7 +- market-maker/configs/portfolio.dev.yml | 17 +- market-maker/configs/portfolio.local.yml | 10 +- market-maker/configs/portfolio.prd.yml | 14 +- market-maker/configs/portfolio.stg.yml | 14 +- market-maker/docs/stale-order-policy.md | 76 ++++++++ market-maker/schemas/futures.json | 82 +++++++-- market-maker/schemas/perps.json | 82 +++++++-- market-maker/schemas/portfolio.json | 82 +++++++-- market-maker/src/apps/futures/main.ts | 5 +- market-maker/src/apps/perps/main.ts | 5 +- market-maker/src/apps/portfolio/main.ts | 5 +- market-maker/src/core/config/base.ts | 34 +++- market-maker/src/core/math.ts | 17 ++ market-maker/src/core/orderExecutor.ts | 132 +++++++++----- market-maker/tests/core/math.test.ts | 22 +++ market-maker/tests/core/orderExecutor.test.ts | 167 +++++++++++++++--- 25 files changed, 649 insertions(+), 180 deletions(-) create mode 100644 market-maker/docs/stale-order-policy.md diff --git a/market-maker/README.md b/market-maker/README.md index 74e1bbb..b43352a 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -73,8 +73,8 @@ graph LR exceeded; throttle if gas budget exceeded 3. **Compute quotes** — N levels per side, spread = max(minSpreadBps, gasFloor) + volatility + inventory skew + gas penalty -4. **Reconcile** — selective requoting: only cancel/place orders that - changed; skips requote if price drift is below threshold or cooldown +4. **Reconcile** — selective requoting: only cancel/place/reduce when + the book is outside the band or size allowance; skips while cooldown hasn't elapsed; skips non-urgent requotes during gas spikes ### Quoting strategy @@ -101,7 +101,7 @@ graph LR - **Daily loss halt**: includes gas costs in PnL calculation; halts if daily loss exceeds `maxDailyLossUsd` - **Gas budget throttle**: rolling hourly/daily gas budgets; when - exceeded, requote cooldown and threshold tighten + exceeded, requote cooldown triples - **Gas spike deferral**: during gas spikes, requotes are deferred unless price drift exceeds `urgentRequoteThresholdTicks` - **Gas cap**: `maxFeePerGas` is capped at `gasCapMultiplier · medianGasPrice` @@ -111,9 +111,10 @@ graph LR ### Stale-order policy -Both venues use limit LOB matching. `OrderExecutor` keeps own orders -that are still at-least-as-aggressive as the worst desired bid/ask -and cancels worse ones. +Both venues use limit LOB matching with a USD keep-zone allowance +(`timing.staleBandAllowanceUsd`). See +[docs/stale-order-policy.md](docs/stale-order-policy.md) for band edges, +when leftovers are kept, and when on-grid size is downsized. ### Graceful shutdown diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index bb5212f..7f4588e 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -46,7 +46,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: 8 # venue-native (contracts base units) - numLevelsPerSide: 6 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -65,15 +65,16 @@ gas: timing: pollIntervalSec: 10 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 500 - maxCollateralAmount: 1000 + maxCollateralAmount: 5000 oracle: # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index e228d9f..882966b 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -37,7 +37,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "10000000" # venue-native (contracts base units) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -56,10 +56,11 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: false diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index 2b718a8..d6cc1ba 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -40,7 +40,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "500000000" # venue-native (contracts base units) - numLevelsPerSide: 6 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -59,15 +59,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - + maxCollateralAmount: 10000 oracle: # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 6d9c715..4965928 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -40,7 +40,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "100000000" # venue-native (contracts base units) - numLevelsPerSide: 6 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -59,15 +59,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - + maxCollateralAmount: 4000 oracle: # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index 6539f5f..a346d67 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -46,7 +46,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "1000000" # venue-native units (hashrate base) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -65,7 +65,6 @@ gas: timing: pollIntervalSec: 30 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 # Perps matching is "limit" — deeper levels fill conditional on shallower @@ -73,11 +72,13 @@ timing: # spread inventory risk along the book. In dev we keep it tight (1 tick) # to match futures and visually verify the level layout. levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 500 - maxCollateralAmount: 1000 + maxCollateralAmount: 5000 oracle: # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index 11687e6..3dce009 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -41,7 +41,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "100000" # venue-native units (hashrate base) - numLevelsPerSide: 3 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -62,10 +62,11 @@ gas: timing: # All *Sec fields are seconds (decimals OK). Loader converts to ms. pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: # Off in dev so you can inspect un-deposited wallet balance. diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index efb7697..49913e7 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -43,7 +43,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "10000000" # venue-native units (hashrate base) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -62,15 +62,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - + maxCollateralAmount: 10000 oracle: # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index a7efcf3..f45a8d9 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -42,7 +42,7 @@ pricing: sizing: strategy: geometric-taper baseQuantity: "1000000" # venue-native units (hashrate base) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 risk: @@ -61,15 +61,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - + maxCollateralAmount: 4000 oracle: # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index 977dbaf..4dcccca 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -50,7 +50,7 @@ venues: sizing: strategy: geometric-taper baseQuantity: "1000000" # venue-native (perps hashrate base units) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures @@ -70,11 +70,10 @@ venues: sizing: strategy: geometric-taper # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 5. Split across `count` expiries; base=4 - # is the integer floor that keeps all 5 taper levels non-zero - # ([8,5,3,1,1]). Order count is 2×levels×expiries (=30), not ×qty. + # baseQuantity×numLevels/1e6 = 10. Split across `count` expiries; base=4 + # keeps taper levels non-zero. Order count is 2×levels×expiries (=60). baseQuantity: 4 # venue-native (contracts) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 @@ -96,17 +95,17 @@ gas: timing: pollIntervalSec: 10 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 500 - # Raised so autoDeposit can top up past the prior 1500 ceiling and leave - # headroom for requotes after fills (util was pinning at ~80%). - maxCollateralAmount: 5000 + # Headroom for 10-level books on perps + 3 futures expiries. + maxCollateralAmount: 10000 oracle: # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index d36be0c..2fef24b 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -41,7 +41,7 @@ venues: sizing: strategy: geometric-taper baseQuantity: "10000000" # venue-native (perps hashrate base units) - numLevelsPerSide: 3 + numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures @@ -61,10 +61,9 @@ venues: sizing: strategy: geometric-taper # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # 10e6×3/1e6 = 30. Split across 3 expiries: 4×5×3 = 60 (taper - # [8,5,3,1,1] keeps all 5 levels non-zero). + # 10e6×10/1e6 = 100. Split across 3 expiries: 4×10×3 = 120. baseQuantity: 4 # venue-native (contracts) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 @@ -86,10 +85,11 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: false diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index a8f84cd..a57de1b 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -46,7 +46,7 @@ venues: sizing: strategy: geometric-taper baseQuantity: "10000000" # venue-native (perps hashrate base units) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures @@ -66,12 +66,11 @@ venues: sizing: strategy: geometric-taper # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 50. Split across `count` expiries: + # baseQuantity×numLevels/1e6 = 100. Split across `count` expiries: # futures_base × futures_levels × expiries ≈ perps_total - # 4 × 5 × 3 = 60 ≈ 50. Taper [8,5,3,1,1] per book. Order count is - # 2×levels×expiries (=30), not ×qty. + # 4 × 10 × 3 = 120 ≈ 100. Order count is 2×levels×expiries (=60). baseQuantity: 4 # venue-native (contracts) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 @@ -93,15 +92,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - maxCollateralAmount: 5000 + maxCollateralAmount: 10000 oracle: windowSize: 60 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml index c4334a5..08f79de 100644 --- a/market-maker/configs/portfolio.stg.yml +++ b/market-maker/configs/portfolio.stg.yml @@ -46,7 +46,7 @@ venues: sizing: strategy: geometric-taper baseQuantity: "1000000" # venue-native (perps hashrate base units) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures @@ -66,11 +66,10 @@ venues: sizing: strategy: geometric-taper # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 5. Split across `count` expiries; base=4 - # is the integer floor that keeps all 5 taper levels non-zero - # ([8,5,3,1,1]). Order count is 2×levels×expiries (=30), not ×qty. + # baseQuantity×numLevels/1e6 = 10. Split across `count` expiries; base=4 + # keeps taper levels non-zero. Order count is 2×levels×expiries (=60). baseQuantity: 4 # venue-native (contracts) - numLevelsPerSide: 5 + numLevelsPerSide: 10 taperRatio: 0.6 # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 @@ -92,15 +91,16 @@ gas: timing: pollIntervalSec: 3 - requoteThresholdTicks: 2 requoteCooldownSec: 1 resyncIntervalSec: 60 levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 1 collateral: autoDeposit: true autoDepositMinAmount: 1 - maxCollateralAmount: 2000 + maxCollateralAmount: 4000 oracle: windowSize: 60 diff --git a/market-maker/docs/stale-order-policy.md b/market-maker/docs/stale-order-policy.md new file mode 100644 index 0000000..dfc3031 --- /dev/null +++ b/market-maker/docs/stale-order-policy.md @@ -0,0 +1,76 @@ +# Stale-order / band policy + +How `OrderExecutor` decides what to cancel, reduce, or place when reconciling the resting book against the quoter’s desired grid. + +## Terms + +- **Desired grid**: current `OrderIntent[]` from `Quoter`. +- **Worst desired bid / ask**: least-aggressive desired buy (min price) / sell (max price). +- **Band allowance** (`timing.staleBandAllowanceUsd`): extra price distance **outside** the worst desired level that still counts as keep. Denominated in USD (6dp price units), independent of venue tick size. Default `0.03` (≈ 3 ticks when tick = $0.01). +- **Keep zone**: + - Bids: `price >= worstDesiredBid - bandAllowance` + - Asks: `price <= worstDesiredAsk + bandAllowance` +- **Size allowance** (`timing.staleSizeAllowanceUsd`): on-grid `|have − want|` tolerance in USD notional (both reduce and top-up). Default `1`. +- **On-grid**: resting `(side, price)` equals a desired intent price. +- **Better leftover**: inside keep zone, more aggressive than the current grid, not on a desired price. +- **Stale / worse**: outside the keep zone. + +## Diff actions + +| Resting order | Action | +|---|---| +| Buy with `price < worstDesiredBid - bandAllowance` | **Cancel** | +| Sell with `price > worstDesiredAsk + bandAllowance` | **Cancel** | +| Side with no desired levels | **Cancel all** on that side | +| Buy/sell inside keep zone but off-grid | **Keep** | +| On-grid, size delta above `staleSizeAllowanceUsd` | **Downsize** or **top-up** (below) | +| On-grid, size delta within `staleSizeAllowanceUsd` | **Keep** (no reduce, no place) | + +Better leftovers are not credited toward a different desired price. Grid slides may place new levels while older in-band orders still rest (temporary extra size/IM). + +## On-grid size allowance + +`timing.staleSizeAllowanceUsd` (default `1`) gates both directions. The USD amount is converted to **venue-native size** at the level price and rounded to the nearest qty unit: + +`allowanceQty = roundNearest(sizeAllowanceUsd × quantityScale / price)` + +- Perps: `quantityScale = 1e6` (same as on-chain quantity decimals). +- Futures: `quantityScale = 1` (size is whole contracts; 1 contract ≈ `$price`). + +Then `|have − want|` is compared to `allowanceQty`: + +- `delta ≤ allowanceQty` → treat as matched (no reduce, no top-up). +- `have > want` and above allowance → **downsize** from the trailing order (FIFO kept): + 1. Trailing `size <= excess` → cancel whole order, continue. + 2. Trailing `size > excess` → reduce-only amend to `size - excess`. +- `have < want` and above allowance → **place** only the deficit at that price. + +At typical hashprices, a `$1` allowance rounds to **0 contracts** on futures (so any 1-contract drift is reconciled) while still absorbing sub-dollar dust on perps. Band price allowance does not affect this size check. + +## Requote gates (`shouldRequote`) + +| Gate | Condition | Threshold | +|---|---|---| +| Cooldown | elapsed since last requote | `requoteCooldownSec` (×3 if gas-budget throttled) | +| Order-count deficit | `ownOrders.size < desired.length` | — | +| Quantity deficit | on-grid size shortfall above size allowance | `staleSizeAllowanceUsd` | +| Stale / excess | band cancels or on-grid downsizes exist | `staleBandAllowanceUsd` + `staleSizeAllowanceUsd` | + +Mid drift is **not** a requote trigger (band + size allowance cover structural changes). Drift is only used after a requote is already warranted: gas spike may still defer unless drift ≥ `urgentRequoteThresholdTicks`. + +With band allowance, small grid slides that stay inside `worst ± bandAllowance` avoid cancel storms; new levels still place when the size delta exceeds the size allowance. + +## Related knobs + +| Knob | Role | +|---|---| +| `timing.staleBandAllowanceUsd` | Outward keep-zone price allowance | +| `timing.staleSizeAllowanceUsd` | On-grid size allowance (reduce and top-up) | +| `timing.levelSpacingTicks` / `sizing.numLevelsPerSide` | Where worst desired edge sits | +| Spreads / vol / skew | Move the grid | +| `risk.maxUtilizationPct` / position caps | Drop a side → cancel that side | +| Gas budget / spike knobs | Throttle or defer requotes | + +## Full cancel-all + +Risk halt, shutdown with `cancelOrdersOnShutdown: true`, futures roll drop, health `/stop` — outside per-tick band diff. diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 96693f5..1eacc2f 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -765,10 +765,11 @@ "type": "object", "required": [ "pollIntervalSec", - "requoteThresholdTicks", "requoteCooldownSec", "resyncIntervalSec", - "levelSpacingTicks" + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" ], "properties": { "pollIntervalSec": { @@ -803,21 +804,6 @@ } ] }, - "requoteThresholdTicks": { - "anyOf": [ - { - "minimum": 0, - "type": "number" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Tick deviation from current target before a resting order is replaced.", - "default": 2 - }, "requoteCooldownSec": { "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", @@ -896,6 +882,68 @@ ], "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", "default": 1 + }, + "staleBandAllowanceUsd": { + "default": 0.03, + "description": "USD price distance outside the worst desired bid/ask that still counts as in-band (kept). Independent of venue tick size. 0 = strict worst-desired edge. Default 0.03 ≈ 3 ticks when tick = $0.01.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "staleSizeAllowanceUsd": { + "default": 1, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] } } }, diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index e5ea99f..9e35c91 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -742,10 +742,11 @@ "type": "object", "required": [ "pollIntervalSec", - "requoteThresholdTicks", "requoteCooldownSec", "resyncIntervalSec", - "levelSpacingTicks" + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" ], "properties": { "pollIntervalSec": { @@ -780,21 +781,6 @@ } ] }, - "requoteThresholdTicks": { - "anyOf": [ - { - "minimum": 0, - "type": "number" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Tick deviation from current target before a resting order is replaced.", - "default": 2 - }, "requoteCooldownSec": { "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", @@ -873,6 +859,68 @@ ], "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", "default": 1 + }, + "staleBandAllowanceUsd": { + "default": 0.03, + "description": "USD price distance outside the worst desired bid/ask that still counts as in-band (kept). Independent of venue tick size. 0 = strict worst-desired edge. Default 0.03 ≈ 3 ticks when tick = $0.01.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "staleSizeAllowanceUsd": { + "default": 1, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] } } }, diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 802db5c..57b1a19 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -1076,10 +1076,11 @@ "type": "object", "required": [ "pollIntervalSec", - "requoteThresholdTicks", "requoteCooldownSec", "resyncIntervalSec", - "levelSpacingTicks" + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" ], "properties": { "pollIntervalSec": { @@ -1114,21 +1115,6 @@ } ] }, - "requoteThresholdTicks": { - "anyOf": [ - { - "minimum": 0, - "type": "number" - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", - "description": "Environment variable interpolation (resolved at startup)" - } - ], - "description": "Tick deviation from current target before a resting order is replaced.", - "default": 2 - }, "requoteCooldownSec": { "default": 1, "description": "Seconds between requote bursts. Tripled when risk is throttled.", @@ -1207,6 +1193,68 @@ ], "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", "default": 1 + }, + "staleBandAllowanceUsd": { + "default": 0.03, + "description": "USD price distance outside the worst desired bid/ask that still counts as in-band (kept). Independent of venue tick size. 0 = strict worst-desired edge. Default 0.03 ≈ 3 ticks when tick = $0.01.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "staleSizeAllowanceUsd": { + "default": 1, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] } } }, diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index 967cde7..d17233f 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -147,8 +147,11 @@ async function main(): Promise { instrument, { requoteCooldownMs: config.timing.requoteCooldownMs, - requoteThresholdTicks: config.timing.requoteThresholdTicks, urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + staleBandAllowance: config.timing.staleBandAllowance, + staleSizeAllowance: config.timing.staleSizeAllowance, + // Futures size is whole contracts; notional ≈ price × contracts. + quantityScale: 1n, dryRun: config.dryRun, }, quoter, diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 4181038..c466fab 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -14,6 +14,7 @@ import { OrderExecutor } from "../../core/orderExecutor.ts"; import { HealthCheck } from "../../core/healthcheck.ts"; import { runMakerLoop } from "../../core/runner.ts"; import { serializeError } from "../../core/errSerializer.ts"; +import { QUANTITY_SCALE } from "../../core/math.ts"; import { createPerpsVenue } from "../../adapters/perps/index.ts"; import { sanitiseConfig } from "../../core/config/base.ts"; import { loadPerpsConfig } from "./config.ts"; @@ -142,7 +143,9 @@ async function main(): Promise { instrument, { requoteCooldownMs: config.timing.requoteCooldownMs, - requoteThresholdTicks: config.timing.requoteThresholdTicks, + staleBandAllowance: config.timing.staleBandAllowance, + staleSizeAllowance: config.timing.staleSizeAllowance, + quantityScale: QUANTITY_SCALE, urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, dryRun: config.dryRun, }, diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index 2462cbd..8915eec 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -31,6 +31,7 @@ import { type PortfolioMakerConfig, } from "./config.ts"; import { expirySizeScale } from "../../core/sizing/expiryDecay.ts"; +import { QUANTITY_SCALE } from "../../core/math.ts"; /** Shared context passed to every market factory. */ interface BuildContext { @@ -150,8 +151,10 @@ function buildMarket( instrument, { requoteCooldownMs: config.timing.requoteCooldownMs, - requoteThresholdTicks: config.timing.requoteThresholdTicks, urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + staleBandAllowance: config.timing.staleBandAllowance, + staleSizeAllowance: config.timing.staleSizeAllowance, + quantityScale: venue.kind === "futures" ? 1n : QUANTITY_SCALE, dryRun: config.dryRun, }, quoter, diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index abf2492..9b5615f 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -146,11 +146,6 @@ export const timingSchema = Type.Object( default: 3, description: "Seconds between main-loop iterations (snapshot, quote, execute).", }), - requoteThresholdTicks: Type.Number({ - minimum: 0, - default: 2, - description: "Tick deviation from current target before a resting order is replaced.", - }), requoteCooldownSec: TypeSeconds({ minimum: 0, default: 1, @@ -166,6 +161,16 @@ export const timingSchema = Type.Object( default: 1, description: "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", }), + staleBandAllowanceUsd: TypeUsdAmount({ + default: 0.03, + description: + "USD price distance outside the worst desired bid/ask that still counts as in-band (kept). Independent of venue tick size. 0 = strict worst-desired edge. Default 0.03 ≈ 3 ticks when tick = $0.01.", + }), + staleSizeAllowanceUsd: TypeUsdAmount({ + default: 1, + description: + "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + }), }, { ...Closed, description: "Loop cadences and requote thresholds." }, ); @@ -297,10 +302,13 @@ export interface ParsedRiskConfig { export interface ParsedTimingConfig { pollIntervalMs: number; - requoteThresholdTicks: number; requoteCooldownMs: number; resyncIntervalMs: number; levelSpacingTicks: number; + /** Price-unit allowance outside worst desired level (6dp USD). */ + staleBandAllowance: bigint; + /** On-grid size allowance in USD notional (6dp); both reduce and top-up. */ + staleSizeAllowance: bigint; } export interface ParsedCollateralConfig { @@ -334,10 +342,11 @@ interface RawRisk { } interface RawTiming { pollIntervalSec: string | number; - requoteThresholdTicks: number; requoteCooldownSec: string | number; resyncIntervalSec: string | number; levelSpacingTicks: number; + staleBandAllowanceUsd?: string | number; + staleSizeAllowanceUsd?: string | number; } interface RawCollateral { autoDeposit: boolean; @@ -376,10 +385,19 @@ export function parseRiskConfig(raw: RawRisk): ParsedRiskConfig { export function parseTimingConfig(raw: RawTiming): ParsedTimingConfig { return { pollIntervalMs: secondsToMs(raw.pollIntervalSec, "timing.pollIntervalSec"), - requoteThresholdTicks: raw.requoteThresholdTicks, requoteCooldownMs: secondsToMs(raw.requoteCooldownSec, "timing.requoteCooldownSec"), resyncIntervalMs: secondsToMs(raw.resyncIntervalSec, "timing.resyncIntervalSec"), levelSpacingTicks: raw.levelSpacingTicks, + staleBandAllowance: parseUsd( + raw.staleBandAllowanceUsd ?? 0.03, + USD_DECIMALS, + "timing.staleBandAllowanceUsd", + ), + staleSizeAllowance: parseUsd( + raw.staleSizeAllowanceUsd ?? 1, + USD_DECIMALS, + "timing.staleSizeAllowanceUsd", + ), }; } diff --git a/market-maker/src/core/math.ts b/market-maker/src/core/math.ts index 0a43080..9d8049c 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -40,6 +40,23 @@ export function calculateNotional(price: bigint, absQuantity: bigint): bigint { return (price * q) / QUANTITY_SCALE; } +/** + * Convert a USD notional amount to venue-native size at `price`, rounded to + * the nearest native unit (half-up). + * + * Inverts `notional = price * size / quantityScale`: + * - perps: `quantityScale = QUANTITY_SCALE` (1e6) + * - futures: `quantityScale = 1n` (size is whole contracts; 1 contract ≈ $price) + */ +export function notionalToSize( + price: bigint, + notionalUsd: bigint, + quantityScale: bigint, +): bigint { + if (price <= 0n || notionalUsd <= 0n || quantityScale <= 0n) return 0n; + return (notionalUsd * quantityScale + price / 2n) / price; +} + /** Apply basis-point offset to a price: price * (BPS_SCALE +/- bps) / BPS_SCALE. */ export function applyBps(price: bigint, bps: bigint): bigint { return (price * (BPS_SCALE + bps)) / BPS_SCALE; diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 7fa3c00..a099be5 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -11,25 +11,43 @@ import type { BookTracker } from "./bookTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; import type { RiskManager } from "./riskManager.ts"; import type { OracleTracker } from "./oracleTracker.ts"; -import { bigAbs } from "./math.ts"; +import { bigAbs, notionalToSize } from "./math.ts"; export interface OrderExecutorConfig { /** Skip a requote if elapsed since last < cooldown (ms). */ requoteCooldownMs: number; - /** Skip if price drifted < N ticks from last quote mid. */ - requoteThresholdTicks: number; - /** Override threshold (in ticks) when gas is spiking — quote anyway if drift >= this. */ + /** During a gas spike, proceed only if mid drift (ticks) is at least this. */ urgentRequoteThresholdTicks: number; + /** + * Price-unit allowance outside the worst desired bid/ask that still counts as + * in-band (kept). Same decimals as book/oracle prices (typically 6dp USD). + * `0n` = strict worst-desired edge. + */ + staleBandAllowance: bigint; + /** + * On-grid size allowance in USD notional (both reduce and top-up). Converted + * to venue-native size at the level price via {@link notionalToSize} + * (rounded nearest) and compared to `|have − want|`. Deltas at or below that + * qty are ignored. `0n` = exact size match required. + */ + staleSizeAllowance: bigint; + /** + * Divisor in `notional = price × size / quantityScale`. + * Perps: `QUANTITY_SCALE` (1e6). Futures: `1n` (whole contracts). + */ + quantityScale: bigint; dryRun: boolean; } /** * Diff desired quotes vs the resting book; cancel + place via venue multicall. * - * Exact set-diff (limit LOB): cancel resting orders whose (side, price) is not - * in the desired grid, or that contribute excess size at a desired price; - * create only size deficits at desired prices. The resting book is driven to - * match the quote grid — no band-based “keep better leftovers” policy. + * Stale-order detection (limit LOB + USD allowance): a resting buy is stale + * iff its price is below `worstDesiredBid − staleBandAllowance`; a resting + * sell is stale iff above `worstDesiredAsk + staleBandAllowance`. Orders + * inside that keep zone (including better-than-grid leftovers) are kept. + * On-grid size is reconciled (reduce / top-up) only when the size delta + * exceeds the USD size allowance converted to native qty (nearest unit). */ export class OrderExecutor { readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; @@ -222,20 +240,14 @@ export class OrderExecutor { return true; } - const drift = this.priceDriftTicks(); - const threshold = this.effectiveRequoteThreshold(); - if (drift >= threshold) { - this.logger.debug({ drift, threshold }, "requote triggered: price drift"); - return true; - } - this.logger.debug( - { drift, threshold, actualCount, expectedCount }, - "requote skipped: no deficit / no stale / no drift", + { actualCount, expectedCount }, + "requote skipped: no deficit / no stale", ); return false; } + /** Oracle mid drift in ticks since the last successful requote (gas-spike gate). */ private priceDriftTicks(): number { if (this.lastQuoteMidPrice === 0n) return Number.POSITIVE_INFINITY; const tick = this.quoter.getTick(); @@ -250,49 +262,74 @@ export class OrderExecutor { : this.cfg.requoteCooldownMs; } - private effectiveRequoteThreshold(): number { - return this.risk.throttled - ? this.cfg.requoteThresholdTicks * 2 - : this.cfg.requoteThresholdTicks; - } - /** - * Cancel / reduce targets for an exact set-diff against `desired`: - * - every resting order at a (side, price) not in the desired grid → cancel - * - at desired prices with excess size: reduce the trailing order in place - * when possible (FIFO kept); cancel whole trailing orders otherwise + * Cancel / reduce targets against `desired`: + * - outside the keep zone (worst desired ± staleBandAllowance) → cancel + * - at desired prices with excess notional above threshold: reduce the + * trailing order in place when possible (FIFO kept); cancel whole + * trailing orders otherwise + * - better leftovers / within-allowance off-grid → keep */ private findStaleOrders(desired: OrderIntent[]): { cancels: OwnOrder[]; reduces: ReduceIntent[]; } { + let worstDesiredBid: bigint | undefined; + let worstDesiredAsk: bigint | undefined; const desiredSize = new Map(); for (const i of desired) { const k = keyOf(i.side, i.price); desiredSize.set(k, (desiredSize.get(k) ?? 0n) + i.size); + if (i.side === "buy") { + if (worstDesiredBid === undefined || i.price < worstDesiredBid) { + worstDesiredBid = i.price; + } + } else if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) { + worstDesiredAsk = i.price; + } } + const allowance = this.cfg.staleBandAllowance; + const cancels: OwnOrder[] = []; + const reduces: ReduceIntent[] = []; const byKey = new Map(); + for (const order of this.book.ownOrders.values()) { + if (order.side === "buy") { + // price + allowance < worst avoids bigint underflow when allowance > price. + if ( + worstDesiredBid === undefined || + order.price + allowance < worstDesiredBid + ) { + cancels.push(order); + continue; + } + } else if ( + worstDesiredAsk === undefined || + order.price > worstDesiredAsk + allowance + ) { + cancels.push(order); + continue; + } + const k = keyOf(order.side, order.price); const list = byKey.get(k); if (list) list.push(order); else byKey.set(k, [order]); } - const cancels: OwnOrder[] = []; - const reduces: ReduceIntent[] = []; for (const [k, orders] of byKey) { const want = desiredSize.get(k); - if (want === undefined) { - for (const o of orders) cancels.push(o); - continue; - } + // Off-grid but inside keep zone (better leftover / within-allowance) → keep. + if (want === undefined) continue; + let have = 0n; for (const o of orders) have += o.size; if (have <= want) continue; - // Trim from the trailing order so earlier FIFO priority is preserved. let excess = have - want; + // Same USD size allowance as top-ups — leave dust oversizing alone. + if (!this.sizeDeltaAboveThreshold(orders[0].price, excess)) continue; + // Trim from the trailing order so earlier FIFO priority is preserved. for (let i = orders.length - 1; i >= 0 && excess > 0n; i--) { const o = orders[i]; if (o.size <= excess) { @@ -315,6 +352,7 @@ export class OrderExecutor { * New orders = desired levels missing size at exactly the desired price, * after applying cancels/reduces from this plan. Size increases only create * the delta — resting orders at that level are never cancelled for a top-up. + * Dust deficits (within staleSizeAllowance) are ignored. */ private findNewOrders( desired: OrderIntent[], @@ -326,7 +364,7 @@ export class OrderExecutor { for (const i of desired) { const have = existing.get(keyOf(i.side, i.price)) ?? 0n; const deficit = i.size - have; - if (deficit > 0n) { + if (deficit > 0n && this.sizeDeltaAboveThreshold(i.price, deficit)) { out.push({ side: i.side, price: i.price, size: deficit }); } } @@ -336,15 +374,29 @@ export class OrderExecutor { private hasQuantityDeficit(desired: OrderIntent[]): boolean { const existing = this.aggregateOwnSizeByPriceSide(); for (const i of desired) { - const have = existing.get(keyOf(i.side, i.price)); - // Deficit means: no orders at this price at all, OR fewer than desired. - // The `undefined` branch catches stale orders at wrong prices that the - // other guards (count, price-drift) would also miss. - if (have === undefined || i.size - have > 0n) return true; + const have = existing.get(keyOf(i.side, i.price)) ?? 0n; + const deficit = i.size - have; + if (deficit > 0n && this.sizeDeltaAboveThreshold(i.price, deficit)) { + return true; + } } return false; } + /** + * True when `delta` exceeds the USD size allowance converted to venue-native + * qty at `price` (nearest unit). Futures (`quantityScale = 1`) rounds to + * whole contracts; perps uses 1e6 scale. + */ + private sizeDeltaAboveThreshold(price: bigint, delta: bigint): boolean { + const allowanceQty = notionalToSize( + price, + this.cfg.staleSizeAllowance, + this.cfg.quantityScale, + ); + return delta > allowanceQty; + } + private aggregateOwnSizeByPriceSide( cancels: OwnOrder[] = [], reduces: ReduceIntent[] = [], diff --git a/market-maker/tests/core/math.test.ts b/market-maker/tests/core/math.test.ts index 01bcf8c..4ccf27a 100644 --- a/market-maker/tests/core/math.test.ts +++ b/market-maker/tests/core/math.test.ts @@ -7,6 +7,8 @@ import { applyBps, bigAbs, calculateNotional, + notionalToSize, + QUANTITY_SCALE, roundDownToTick, roundToTick, roundUpToTick, @@ -40,6 +42,26 @@ describe("calculateNotional", () => { }); }); +describe("notionalToSize", () => { + it("perps: inverts calculateNotional with nearest-unit rounding", () => { + assert.equal(notionalToSize(100_000_000n, 100_000_000n, QUANTITY_SCALE), 1_000_000n); + // $1 at $95 → ≈ 10526.315 → rounds to 10526 + assert.equal(notionalToSize(95_000_000n, 1_000_000n, QUANTITY_SCALE), 10_526n); + }); + it("futures: scale 1 rounds USD size allowance to whole contracts", () => { + // $1 at $95 → 0.0105 → 0 contracts + assert.equal(notionalToSize(95_000_000n, 1_000_000n, 1n), 0n); + // $50 at $95 → 0.526 → 1 contract + assert.equal(notionalToSize(95_000_000n, 50_000_000n, 1n), 1n); + // $95 at $95 → 1 contract exactly + assert.equal(notionalToSize(95_000_000n, 95_000_000n, 1n), 1n); + }); + it("returns 0 for non-positive inputs", () => { + assert.equal(notionalToSize(0n, 1_000_000n, QUANTITY_SCALE), 0n); + assert.equal(notionalToSize(95_000_000n, 0n, QUANTITY_SCALE), 0n); + }); +}); + describe("applyBps", () => { it("adds positive bps", () => { assert.equal(applyBps(10_000n, 100n), 10_100n); diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 5b3b5f9..4cbef1c 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -27,13 +27,22 @@ function makeOrderId(n: number): `0x${string}` { return `0x${n.toString(16).padStart(64, "0")}` as `0x${string}`; } +/** Default $0.03 allowance (≈ 3 × $0.01 tick used by the stub quoter). */ +const DEFAULT_ALLOWANCE = 30_000n; +/** Default $1 size allowance (converted to native qty at level price). */ +const DEFAULT_SIZE_ALLOWANCE = 1_000_000n; +/** Perps quantity scale (tests default). Futures tests pass `1n`. */ +const PERPS_QUANTITY_SCALE = 1_000_000n; + function makeConfig( overrides: Partial = {}, ): OrderExecutorConfig { return { requoteCooldownMs: 0, - requoteThresholdTicks: 2, urgentRequoteThresholdTicks: 10, + staleBandAllowance: DEFAULT_ALLOWANCE, + staleSizeAllowance: DEFAULT_SIZE_ALLOWANCE, + quantityScale: PERPS_QUANTITY_SCALE, dryRun: false, ...overrides, }; @@ -94,10 +103,13 @@ function makeDeps(overrides: Partial = {}): TestDeps { return deps; } -function makeExecutor(deps: TestDeps): OrderExecutor { +function makeExecutor( + deps: TestDeps, + cfg: Partial = {}, +): OrderExecutor { return new OrderExecutor( deps.instrument, - makeConfig(), + makeConfig(cfg), deps.quoter, deps.book, deps.gas, @@ -167,17 +179,17 @@ describe("OrderExecutor requote guards (regression)", () => { }); /** - * Off-grid leftovers coexist with correct grid orders — cancel only the - * off-grid ones (exact set-diff). + * Worse leftovers coexist with correct grid orders — cancel only the worse + * ones (outside keep zone). Better-than-grid leftovers are kept. */ - it("cancels off-grid leftovers while keeping the desired grid", async () => { + it("cancels worse leftovers while keeping the desired grid", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - // Off-grid leftovers (cancels failed on a prior tick). + // Worse leftovers well outside the $0.03 allowance. seedOrder(deps.book, 3, "buy", 90_000_000n, 1_000_000n); seedOrder(deps.book, 4, "sell", 101_000_000n, 1_000_000n); @@ -188,7 +200,7 @@ describe("OrderExecutor requote guards (regression)", () => { await executor.reconcile(desired); - assert.equal(deps.cancelledOrderIds.length, 2, "off-grid leftovers cancelled"); + assert.equal(deps.cancelledOrderIds.length, 2, "worse leftovers cancelled"); assert.equal( deps.placedIntents.length, 0, @@ -196,18 +208,18 @@ describe("OrderExecutor requote guards (regression)", () => { ); }); - it("cancels better-than-grid leftovers not on the desired set", async () => { + it("keeps better-than-grid leftovers (does not cancel them as stale)", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid, off-grid - seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask, off-grid + seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid + seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); - assert.equal(deps.cancelledOrderIds.length, 2, "off-grid leftovers cancelled"); + assert.equal(deps.cancelledOrderIds.length, 0, "better leftovers kept"); assert.equal(deps.placedIntents.length, 0); }); @@ -251,7 +263,8 @@ describe("OrderExecutor quantity deficit", () => { it("requotes when resting size falls below desired qty at a level", () => { const deps = makeDeps(); - const executor = makeExecutor(deps); + // Abstract tiny sizes — force size allowance off so the top-up path is exercised. + const executor = makeExecutor(deps, { staleSizeAllowance: 0n }); seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); seedOrder(deps.book, 2, "sell", 96_000_000n, 3n); @@ -264,6 +277,21 @@ describe("OrderExecutor quantity deficit", () => { assert.equal(planned.creates.length, 1, "tops up the missing buy size"); assert.equal(planned.creates[0].size, 1n); }); + + it("skips top-up when deficit notional is at or below the USD threshold", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); // $1 default size allowance + // deficit 10 at price 95 → notional ≈ $0.00095 ≤ $1 + seedOrder(deps.book, 1, "buy", 95_000_000n, 999_990n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + + executor.recordRequote(0, 0); + const planned = executor.plan([ + desiredBuy(95_000_000n, 1_000_000n), + desiredSell(96_000_000n, 1_000_000n), + ]); + assert.equal(planned, null, "sub-threshold dust deficit must not requote"); + }); }); // ── plan() / cooldown / gas-spike deferral ───────────────────────────────── @@ -314,7 +342,7 @@ describe("OrderExecutor.plan", () => { assert.equal(executor.plan([desiredBuy(95_000_000n)]), null); }); - it("returns null when there is no deficit, no stale order, and no drift", () => { + it("returns null when there is no deficit and no stale order", () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n); @@ -339,13 +367,13 @@ describe("OrderExecutor.plan", () => { // ── stale detection ──────────────────────────────────────────────────────── describe("OrderExecutor stale detection", () => { - it("cancels off-grid prices and keeps exact desired levels", () => { + it("cancels outside the keep zone and keeps on-grid levels", () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n); // on-grid → keep - seedOrder(deps.book, 2, "buy", 93_000_000n); // off-grid → cancel + seedOrder(deps.book, 2, "buy", 93_000_000n); // worse than worstBid 94 − 0.03 → cancel seedOrder(deps.book, 3, "sell", 96_000_000n); // on-grid → keep - seedOrder(deps.book, 4, "sell", 98_000_000n); // off-grid → cancel + seedOrder(deps.book, 4, "sell", 98_000_000n); // worse than worstAsk 97 + 0.03 → cancel const planned = executor.plan([ desiredBuy(95_000_000n), @@ -355,31 +383,54 @@ describe("OrderExecutor stale detection", () => { ]); assert.ok(planned); const cancelled = new Set(planned.cancels.map((o) => o.orderId)); - assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "off-grid cancelled"); + assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "outside band cancelled"); assert.ok(!cancelled.has(makeOrderId(1)) && !cancelled.has(makeOrderId(3)), "on-grid kept"); assert.equal(planned.creates.length, 2, "missing grid levels placed"); }); + it("keeps slightly-worse leftovers within the USD allowance", () => { + const deps = makeDeps(); + // allowance $0.03; tick stub is $0.01 → 2 ticks inside keep zone past worst edge + const executor = makeExecutor(deps, { staleBandAllowance: DEFAULT_ALLOWANCE }); + seedOrder(deps.book, 1, "buy", 95_000_000n); + seedOrder(deps.book, 2, "buy", 94_980_000n); // 95 − 0.02 → keep + seedOrder(deps.book, 3, "sell", 96_000_000n); + seedOrder(deps.book, 4, "sell", 96_020_000n); // 96 + 0.02 → keep + seedOrder(deps.book, 5, "buy", 94_960_000n); // 95 − 0.04 → cancel + seedOrder(deps.book, 6, "sell", 96_040_000n); // 96 + 0.04 → cancel + + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(5)) && cancelled.has(makeOrderId(6)), "beyond allowance cancelled"); + assert.ok( + !cancelled.has(makeOrderId(2)) && !cancelled.has(makeOrderId(4)), + "within-allowance leftovers kept", + ); + assert.equal(planned.creates.length, 0); + }); + it("cancels every resting order on a side when that side is absent from the grid", () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 96_000_000n); seedOrder(deps.book, 2, "buy", 93_000_000n); seedOrder(deps.book, 3, "sell", 96_000_000n); // on desired ask → keep - seedOrder(deps.book, 4, "sell", 98_000_000n); // off-grid → cancel + seedOrder(deps.book, 4, "sell", 98_000_000n); // outside keep zone → cancel // Desired has only an ask side → no desired bid → all resting buys cancel. const planned = executor.plan([desiredSell(96_000_000n)]); assert.ok(planned); const cancelled = new Set(planned.cancels.map((o) => o.orderId)); assert.ok(cancelled.has(makeOrderId(1)) && cancelled.has(makeOrderId(2)), "all bids cancelled"); - assert.ok(cancelled.has(makeOrderId(4)), "off-grid ask cancelled"); + assert.ok(cancelled.has(makeOrderId(4)), "outside-band ask cancelled"); assert.ok(!cancelled.has(makeOrderId(3)), "on-grid ask kept"); }); it("cancels a whole trailing order when excess covers it", () => { const deps = makeDeps(); - const executor = makeExecutor(deps); + // Tiny abstract sizes → force threshold off so the trim path is exercised. + const executor = makeExecutor(deps, { staleSizeAllowance: 0n }); seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); seedOrder(deps.book, 2, "buy", 95_000_000n, 2n); // aggregate 4 > desired 2 @@ -393,7 +444,7 @@ describe("OrderExecutor stale detection", () => { it("reduces trailing order in place when excess is partial", () => { const deps = makeDeps(); - const executor = makeExecutor(deps); + const executor = makeExecutor(deps, { staleSizeAllowance: 0n }); seedOrder(deps.book, 1, "buy", 95_000_000n, 4n); const planned = executor.plan([desiredBuy(95_000_000n, 3n)]); @@ -404,6 +455,78 @@ describe("OrderExecutor stale detection", () => { assert.equal(planned.reduces[0].newSize, 3n); assert.equal(planned.creates.length, 0); }); + + it("skips downsize when excess notional is at or below the USD threshold", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); // $1 default size allowance + // excess 10 at price 95 → notional ≈ $0.00095 → keep + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_010n); + + executor.recordRequote(0, 0); + const planned = executor.plan([desiredBuy(95_000_000n, 1_000_000n)]); + assert.equal(planned, null, "sub-threshold dust excess must not requote"); + }); + + it("downsizes when excess notional exceeds the USD threshold", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + // excess 20_000 at price 95 → notional = 95×20000/1e6 = $1.90 > $1 + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_020_000n); + + const planned = executor.plan([desiredBuy(95_000_000n, 1_000_000n)]); + assert.ok(planned); + assert.equal(planned.reduces.length, 1); + assert.equal(planned.reduces[0].newSize, 1_000_000n); + assert.equal(planned.cancels.length, 0); + }); + + it("tops up when deficit notional exceeds the same USD threshold", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + // deficit 20_000 at price 95 → allowanceQty ≈ 10526; 20000 > allowance → top up + seedOrder(deps.book, 1, "buy", 95_000_000n, 980_000n); + + const planned = executor.plan([desiredBuy(95_000_000n, 1_000_000n)]); + assert.ok(planned); + assert.equal(planned.creates.length, 1); + assert.equal(planned.creates[0].size, 20_000n); + assert.equal(planned.cancels.length, 0); + assert.equal(planned.reduces.length, 0); + }); + + it("futures: rounds USD size allowance to nearest contract (qty scale 1)", () => { + const deps = makeDeps(); + // $1 at price $95 → allowanceQty = round(1/95) = 0 contracts → any 1-lot drift acts + const executor = makeExecutor(deps, { quantityScale: 1n }); + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); + + const planned = executor.plan([desiredBuy(95_000_000n, 1n)]); + assert.ok(planned, "1-contract excess must downsize on futures"); + assert.equal(planned.reduces.length, 1); + assert.equal(planned.reduces[0].newSize, 1n); + }); + + it("futures: multi-contract size allowance rounds to whole lots", () => { + const deps = makeDeps(); + // $50 at price $95 → allowanceQty = round(50/95) = 1 contract + const executor = makeExecutor(deps, { + quantityScale: 1n, + staleSizeAllowance: 50_000_000n, + }); + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); // excess 1 ≤ allowance 1 → keep + + executor.recordRequote(0, 0); + assert.equal( + executor.plan([desiredBuy(95_000_000n, 1n)]), + null, + "1-contract excess within rounded size allowance", + ); + + seedOrder(deps.book, 2, "buy", 95_000_000n, 1n); // have 3, excess 2 > allowance 1 + const planned = executor.plan([desiredBuy(95_000_000n, 1n)]); + assert.ok(planned); + assert.ok(planned.cancels.length + planned.reduces.length > 0); + }); }); // ── reconcile() gate + cancelAll ─────────────────────────────────────────── From 6ed56736dbfee0a6ee083bb73c82b434d6a2766a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 18:42:27 +0200 Subject: [PATCH 115/180] chore(mm): raise staleSizeAllowanceUsd default to 50 Match ~1 futures contract of on-grid size slack at typical hashprices so small lot drift does not force reduce/top-up churn. Co-authored-by: Cursor --- market-maker/configs/futures.dev.yml | 2 +- market-maker/configs/futures.local.yml | 2 +- market-maker/configs/futures.prd.yml | 2 +- market-maker/configs/futures.stg.yml | 2 +- market-maker/configs/perps.dev.yml | 2 +- market-maker/configs/perps.local.yml | 2 +- market-maker/configs/perps.prd.yml | 2 +- market-maker/configs/perps.stg.yml | 2 +- market-maker/configs/portfolio.dev.yml | 2 +- market-maker/configs/portfolio.local.yml | 2 +- market-maker/configs/portfolio.prd.yml | 2 +- market-maker/configs/portfolio.stg.yml | 2 +- market-maker/docs/stale-order-policy.md | 6 +-- market-maker/schemas/futures.json | 4 +- market-maker/schemas/perps.json | 4 +- market-maker/schemas/portfolio.json | 4 +- market-maker/src/core/config/base.ts | 6 +-- market-maker/tests/core/orderExecutor.test.ts | 43 ++++++------------- 18 files changed, 38 insertions(+), 53 deletions(-) diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml index 7f4588e..02856ea 100644 --- a/market-maker/configs/futures.dev.yml +++ b/market-maker/configs/futures.dev.yml @@ -69,7 +69,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml index 882966b..6c46f23 100644 --- a/market-maker/configs/futures.local.yml +++ b/market-maker/configs/futures.local.yml @@ -60,7 +60,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: false diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml index d6cc1ba..78c796f 100644 --- a/market-maker/configs/futures.prd.yml +++ b/market-maker/configs/futures.prd.yml @@ -63,7 +63,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml index 4965928..b560c94 100644 --- a/market-maker/configs/futures.stg.yml +++ b/market-maker/configs/futures.stg.yml @@ -63,7 +63,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml index a346d67..9eebf9a 100644 --- a/market-maker/configs/perps.dev.yml +++ b/market-maker/configs/perps.dev.yml @@ -73,7 +73,7 @@ timing: # to match futures and visually verify the level layout. levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml index 3dce009..24fe3d9 100644 --- a/market-maker/configs/perps.local.yml +++ b/market-maker/configs/perps.local.yml @@ -66,7 +66,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 5 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: # Off in dev so you can inspect un-deposited wallet balance. diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml index 49913e7..b16bd18 100644 --- a/market-maker/configs/perps.prd.yml +++ b/market-maker/configs/perps.prd.yml @@ -66,7 +66,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 5 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml index f45a8d9..821792c 100644 --- a/market-maker/configs/perps.stg.yml +++ b/market-maker/configs/perps.stg.yml @@ -65,7 +65,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 5 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index 4dcccca..c12594a 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -99,7 +99,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index 2fef24b..9b4f980 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -89,7 +89,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: false diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index a57de1b..bd826f1 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -96,7 +96,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml index 08f79de..e2b0b37 100644 --- a/market-maker/configs/portfolio.stg.yml +++ b/market-maker/configs/portfolio.stg.yml @@ -95,7 +95,7 @@ timing: resyncIntervalSec: 60 levelSpacingTicks: 1 staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 1 + staleSizeAllowanceUsd: 50 collateral: autoDeposit: true diff --git a/market-maker/docs/stale-order-policy.md b/market-maker/docs/stale-order-policy.md index dfc3031..48b627d 100644 --- a/market-maker/docs/stale-order-policy.md +++ b/market-maker/docs/stale-order-policy.md @@ -10,7 +10,7 @@ How `OrderExecutor` decides what to cancel, reduce, or place when reconciling th - **Keep zone**: - Bids: `price >= worstDesiredBid - bandAllowance` - Asks: `price <= worstDesiredAsk + bandAllowance` -- **Size allowance** (`timing.staleSizeAllowanceUsd`): on-grid `|have − want|` tolerance in USD notional (both reduce and top-up). Default `1`. +- **Size allowance** (`timing.staleSizeAllowanceUsd`): on-grid `|have − want|` tolerance in USD notional (both reduce and top-up). Default `50` (~1 futures contract at ~$95). - **On-grid**: resting `(side, price)` equals a desired intent price. - **Better leftover**: inside keep zone, more aggressive than the current grid, not on a desired price. - **Stale / worse**: outside the keep zone. @@ -30,7 +30,7 @@ Better leftovers are not credited toward a different desired price. Grid slides ## On-grid size allowance -`timing.staleSizeAllowanceUsd` (default `1`) gates both directions. The USD amount is converted to **venue-native size** at the level price and rounded to the nearest qty unit: +`timing.staleSizeAllowanceUsd` (default `50`) gates both directions. The USD amount is converted to **venue-native size** at the level price and rounded to the nearest qty unit: `allowanceQty = roundNearest(sizeAllowanceUsd × quantityScale / price)` @@ -45,7 +45,7 @@ Then `|have − want|` is compared to `allowanceQty`: 2. Trailing `size > excess` → reduce-only amend to `size - excess`. - `have < want` and above allowance → **place** only the deficit at that price. -At typical hashprices, a `$1` allowance rounds to **0 contracts** on futures (so any 1-contract drift is reconciled) while still absorbing sub-dollar dust on perps. Band price allowance does not affect this size check. +At ~`$95` hashprice, a `$50` allowance rounds to **1 contract** on futures and ~0.5 qty units on perps. Band price allowance does not affect this size check. ## Requote gates (`shouldRequote`) diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 1eacc2f..02eb4a2 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -915,8 +915,8 @@ ] }, "staleSizeAllowanceUsd": { - "default": 1, - "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "default": 50, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 50 (~1 futures contract at ~$95).", "anyOf": [ { "anyOf": [ diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 9e35c91..1929c59 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -892,8 +892,8 @@ ] }, "staleSizeAllowanceUsd": { - "default": 1, - "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "default": 50, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 50 (~1 futures contract at ~$95).", "anyOf": [ { "anyOf": [ diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 57b1a19..2471905 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -1226,8 +1226,8 @@ ] }, "staleSizeAllowanceUsd": { - "default": 1, - "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "default": 50, + "description": "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 50 (~1 futures contract at ~$95).", "anyOf": [ { "anyOf": [ diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index 9b5615f..70905e9 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -167,9 +167,9 @@ export const timingSchema = Type.Object( "USD price distance outside the worst desired bid/ask that still counts as in-band (kept). Independent of venue tick size. 0 = strict worst-desired edge. Default 0.03 ≈ 3 ticks when tick = $0.01.", }), staleSizeAllowanceUsd: TypeUsdAmount({ - default: 1, + default: 50, description: - "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 1.", + "USD notional size allowance (reduce and top-up). Converted to venue-native qty at the level price (nearest unit; perps 1e6 scale, futures whole contracts) and compared to |have−want|. 0 = exact size match. Default 50 (~1 futures contract at ~$95).", }), }, { ...Closed, description: "Loop cadences and requote thresholds." }, @@ -394,7 +394,7 @@ export function parseTimingConfig(raw: RawTiming): ParsedTimingConfig { "timing.staleBandAllowanceUsd", ), staleSizeAllowance: parseUsd( - raw.staleSizeAllowanceUsd ?? 1, + raw.staleSizeAllowanceUsd ?? 50, USD_DECIMALS, "timing.staleSizeAllowanceUsd", ), diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 4cbef1c..0e93f24 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -29,8 +29,8 @@ function makeOrderId(n: number): `0x${string}` { /** Default $0.03 allowance (≈ 3 × $0.01 tick used by the stub quoter). */ const DEFAULT_ALLOWANCE = 30_000n; -/** Default $1 size allowance (converted to native qty at level price). */ -const DEFAULT_SIZE_ALLOWANCE = 1_000_000n; +/** Default $50 size allowance (converted to native qty at level price). */ +const DEFAULT_SIZE_ALLOWANCE = 50_000_000n; /** Perps quantity scale (tests default). Futures tests pass `1n`. */ const PERPS_QUANTITY_SCALE = 1_000_000n; @@ -280,8 +280,8 @@ describe("OrderExecutor quantity deficit", () => { it("skips top-up when deficit notional is at or below the USD threshold", () => { const deps = makeDeps(); - const executor = makeExecutor(deps); // $1 default size allowance - // deficit 10 at price 95 → notional ≈ $0.00095 ≤ $1 + const executor = makeExecutor(deps); // $50 default size allowance + // deficit 10 at price 95 → notional ≈ $0.00095 ≤ $50 seedOrder(deps.book, 1, "buy", 95_000_000n, 999_990n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); @@ -458,7 +458,7 @@ describe("OrderExecutor stale detection", () => { it("skips downsize when excess notional is at or below the USD threshold", () => { const deps = makeDeps(); - const executor = makeExecutor(deps); // $1 default size allowance + const executor = makeExecutor(deps); // $50 default size allowance // excess 10 at price 95 → notional ≈ $0.00095 → keep seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_010n); @@ -470,8 +470,8 @@ describe("OrderExecutor stale detection", () => { it("downsizes when excess notional exceeds the USD threshold", () => { const deps = makeDeps(); const executor = makeExecutor(deps); - // excess 20_000 at price 95 → notional = 95×20000/1e6 = $1.90 > $1 - seedOrder(deps.book, 1, "buy", 95_000_000n, 1_020_000n); + // $50 at $95 → allowanceQty ≈ 526316; excess 600_000 > allowance → reduce + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_600_000n); const planned = executor.plan([desiredBuy(95_000_000n, 1_000_000n)]); assert.ok(planned); @@ -483,37 +483,22 @@ describe("OrderExecutor stale detection", () => { it("tops up when deficit notional exceeds the same USD threshold", () => { const deps = makeDeps(); const executor = makeExecutor(deps); - // deficit 20_000 at price 95 → allowanceQty ≈ 10526; 20000 > allowance → top up - seedOrder(deps.book, 1, "buy", 95_000_000n, 980_000n); + // deficit 600_000 at price 95 → above ~526316 allowance → top up + seedOrder(deps.book, 1, "buy", 95_000_000n, 400_000n); const planned = executor.plan([desiredBuy(95_000_000n, 1_000_000n)]); assert.ok(planned); assert.equal(planned.creates.length, 1); - assert.equal(planned.creates[0].size, 20_000n); + assert.equal(planned.creates[0].size, 600_000n); assert.equal(planned.cancels.length, 0); assert.equal(planned.reduces.length, 0); }); - it("futures: rounds USD size allowance to nearest contract (qty scale 1)", () => { + it("futures: $50 size allowance rounds to 1 contract at ~$95", () => { const deps = makeDeps(); - // $1 at price $95 → allowanceQty = round(1/95) = 0 contracts → any 1-lot drift acts + // default $50 → allowanceQty = round(50/95) = 1 contract const executor = makeExecutor(deps, { quantityScale: 1n }); - seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); - - const planned = executor.plan([desiredBuy(95_000_000n, 1n)]); - assert.ok(planned, "1-contract excess must downsize on futures"); - assert.equal(planned.reduces.length, 1); - assert.equal(planned.reduces[0].newSize, 1n); - }); - - it("futures: multi-contract size allowance rounds to whole lots", () => { - const deps = makeDeps(); - // $50 at price $95 → allowanceQty = round(50/95) = 1 contract - const executor = makeExecutor(deps, { - quantityScale: 1n, - staleSizeAllowance: 50_000_000n, - }); - seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); // excess 1 ≤ allowance 1 → keep + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); // excess 1 ≤ 1 → keep executor.recordRequote(0, 0); assert.equal( @@ -522,7 +507,7 @@ describe("OrderExecutor stale detection", () => { "1-contract excess within rounded size allowance", ); - seedOrder(deps.book, 2, "buy", 95_000_000n, 1n); // have 3, excess 2 > allowance 1 + seedOrder(deps.book, 2, "buy", 95_000_000n, 1n); // have 3, excess 2 > 1 const planned = executor.plan([desiredBuy(95_000_000n, 1n)]); assert.ok(planned); assert.ok(planned.cancels.length + planned.reduces.length > 0); From 420b72215025c2190e2e90e43d6aa1fa4c3ab43f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 18:49:59 +0200 Subject: [PATCH 116/180] test(mm): cover band/size allowance grid-slide and strict mode Add integration plan() cases for combined keep/cancel/reduce behavior, zero-allowance exact matching, mid-drift no-op, and config parse of the new USD timing knobs. Co-authored-by: Cursor --- market-maker/tests/apps/perps/config.test.ts | 16 +++ market-maker/tests/core/orderExecutor.test.ts | 103 ++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/market-maker/tests/apps/perps/config.test.ts b/market-maker/tests/apps/perps/config.test.ts index a9ff320..a599c52 100644 --- a/market-maker/tests/apps/perps/config.test.ts +++ b/market-maker/tests/apps/perps/config.test.ts @@ -69,6 +69,22 @@ describe("loadPerpsConfig", () => { assert.strictEqual(cfg.sizing.taperRatio, 0.6); }); + it("defaults stale band/size allowances and accepts explicit USD values", () => { + const pathDefaults = writeTmp(tmpDir, "test.yml", VALID_YAML); + const defaults = loadPerpsConfig({ path: pathDefaults }); + assert.equal(defaults.timing.staleBandAllowance, 30_000n); // $0.03 + assert.equal(defaults.timing.staleSizeAllowance, 50_000_000n); // $50 + + const withExplicit = VALID_YAML.replace( + "timing:\n pollIntervalSec: 3\n", + "timing:\n pollIntervalSec: 3\n staleBandAllowanceUsd: 0.05\n staleSizeAllowanceUsd: 25\n", + ); + const pathExplicit = writeTmp(tmpDir, "explicit.yml", withExplicit); + const explicit = loadPerpsConfig({ path: pathExplicit }); + assert.equal(explicit.timing.staleBandAllowance, 50_000n); // $0.05 + assert.equal(explicit.timing.staleSizeAllowance, 25_000_000n); // $25 + }); + it("rejects reservation-price strategy on perps", () => { const yaml = VALID_YAML .replace("strategy: effective-spread", "strategy: reservation-price") diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 0e93f24..1c38f8d 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -514,6 +514,109 @@ describe("OrderExecutor stale detection", () => { }); }); +// ── combined band + size (grid-slide / strict mode) ──────────────────────── + +describe("OrderExecutor band + size allowance integration", () => { + it("on a 1-tick grid slide: places new levels, keeps in-band leftovers, cancels outside", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps, { + staleBandAllowance: DEFAULT_ALLOWANCE, // $0.03 + staleSizeAllowance: DEFAULT_SIZE_ALLOWANCE, + }); + + // Prior book at mid≈95.5: bid@95 / ask@96 (full size). + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + // Leftover from an earlier failed cancel — slightly worse bid, still in band + // once the grid slides to worstBid=94.99 (94_990_000): 94.98 >= 94.99−0.03. + seedOrder(deps.book, 3, "buy", 94_980_000n, 1_000_000n); + // Far worse ask — outside new worstAsk=97.01 + 0.03. + seedOrder(deps.book, 4, "sell", 98_000_000n, 1_000_000n); + + // Grid slides up one tick on each side (new levels at 94.99 / 97.01). + const planned = executor.plan([ + desiredBuy(95_000_000n), + desiredBuy(94_990_000n), + desiredSell(96_000_000n), + desiredSell(97_010_000n), + ]); + assert.ok(planned); + + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(4)), "far ask cancelled"); + assert.ok(!cancelled.has(makeOrderId(1)), "old on-grid bid kept (now better leftover)"); + assert.ok(!cancelled.has(makeOrderId(2)), "old on-grid ask kept"); + assert.ok(!cancelled.has(makeOrderId(3)), "in-band worse bid kept"); + + const createdPrices = new Set(planned.creates.map((c) => c.price)); + assert.ok(createdPrices.has(94_990_000n), "new bid level placed"); + assert.ok(createdPrices.has(97_010_000n), "new ask level placed"); + assert.equal(planned.reduces.length, 0, "no size trim on this slide"); + }); + + it("keeps in-band leftover while downsizing on-grid excess above size allowance", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + // On-grid bid with large excess (>$50) → reduce. + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_600_000n); + // Better leftover bid — inside band, off-grid → keep (not trimmed for size). + seedOrder(deps.book, 2, "buy", 99_000_000n, 1_000_000n); + seedOrder(deps.book, 3, "sell", 96_000_000n, 1_000_000n); + + const planned = executor.plan([ + desiredBuy(95_000_000n, 1_000_000n), + desiredSell(96_000_000n, 1_000_000n), + ]); + assert.ok(planned); + assert.equal(planned.reduces.length, 1); + assert.equal(planned.reduces[0].orderId, makeOrderId(1)); + assert.equal(planned.reduces[0].newSize, 1_000_000n); + assert.equal(planned.cancels.length, 0, "better leftover must not be cancelled"); + assert.equal(planned.creates.length, 0); + }); + + it("strict mode (zero allowances): cancels any off-grid and trims any size excess", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps, { + staleBandAllowance: 0n, + staleSizeAllowance: 0n, + }); + + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_010n); // tiny excess → trim + seedOrder(deps.book, 2, "buy", 94_990_000n, 1_000_000n); // 1 tick worse, no band slack → cancel + seedOrder(deps.book, 3, "sell", 96_000_000n, 1_000_000n); + + const planned = executor.plan([ + desiredBuy(95_000_000n, 1_000_000n), + desiredSell(96_000_000n, 1_000_000n), + ]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(2)), "off-grid cancelled with zero band allowance"); + assert.ok(!cancelled.has(makeOrderId(1)), "on-grid kept for reduce"); + assert.equal(planned.reduces.length, 1); + assert.equal(planned.reduces[0].newSize, 1_000_000n); + assert.equal(planned.creates.length, 0); + }); + + it("does not requote on mid move when book stays inside band and size allowance", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + // Book matches desired; mid can move but structural gates stay clean. + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + executor.recordRequote(0, 0); + // Simulate oracle mid drift without changing the desired grid. + (deps.oracle as { currentPrice: bigint }).currentPrice = 100_050_000n; + + assert.equal( + executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]), + null, + "mid drift alone must not trigger requote after band/size gates", + ); + }); +}); + // ── reconcile() gate + cancelAll ─────────────────────────────────────────── describe("OrderExecutor reconcile gate and cancelAll", () => { From c7a941bc40d0ebcae11fd2ff560b6e5f9f0c1317 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 22 Jul 2026 18:57:04 +0200 Subject: [PATCH 117/180] fix(mm): cancel better leftovers that would self-match Drop aggressive off-grid and lock/cross resting orders before creates, and widen a locked best ask by one tick after quote rounding so the MM does not wash against its own book. Co-authored-by: Cursor --- market-maker/docs/stale-order-policy.md | 12 ++++-- market-maker/src/core/orderExecutor.ts | 42 +++++++++++++++---- market-maker/src/core/quoter.ts | 26 ++++++++++++ market-maker/tests/core/orderExecutor.test.ts | 32 ++++++++++---- 4 files changed, 94 insertions(+), 18 deletions(-) diff --git a/market-maker/docs/stale-order-policy.md b/market-maker/docs/stale-order-policy.md index 48b627d..af2e69f 100644 --- a/market-maker/docs/stale-order-policy.md +++ b/market-maker/docs/stale-order-policy.md @@ -12,8 +12,9 @@ How `OrderExecutor` decides what to cancel, reduce, or place when reconciling th - Asks: `price <= worstDesiredAsk + bandAllowance` - **Size allowance** (`timing.staleSizeAllowanceUsd`): on-grid `|have − want|` tolerance in USD notional (both reduce and top-up). Default `50` (~1 futures contract at ~$95). - **On-grid**: resting `(side, price)` equals a desired intent price. -- **Better leftover**: inside keep zone, more aggressive than the current grid, not on a desired price. -- **Stale / worse**: outside the keep zone. +- **Worse leftover**: inside keep zone, less aggressive than the best desired level on that side (still within band allowance of the worst level). +- **Better leftover**: more aggressive than the best desired bid/ask — **cancelled** (self-match prevention). +- **Stale / worse-outside-band**: outside the keep zone. ## Diff actions @@ -21,12 +22,15 @@ How `OrderExecutor` decides what to cancel, reduce, or place when reconciling th |---|---| | Buy with `price < worstDesiredBid - bandAllowance` | **Cancel** | | Sell with `price > worstDesiredAsk + bandAllowance` | **Cancel** | +| Buy with `price > bestDesiredBid` (better leftover) | **Cancel** (STP) | +| Sell with `price < bestDesiredAsk` (better leftover) | **Cancel** (STP) | +| Buy with `price >= bestDesiredAsk` / sell with `price <= bestDesiredBid` | **Cancel** (would lock/cross) | | Side with no desired levels | **Cancel all** on that side | -| Buy/sell inside keep zone but off-grid | **Keep** | +| Worse leftover inside keep zone | **Keep** | | On-grid, size delta above `staleSizeAllowanceUsd` | **Downsize** or **top-up** (below) | | On-grid, size delta within `staleSizeAllowanceUsd` | **Keep** (no reduce, no place) | -Better leftovers are not credited toward a different desired price. Grid slides may place new levels while older in-band orders still rest (temporary extra size/IM). +Worse-within-band leftovers may rest while new on-grid levels are placed. Better leftovers are never kept — they would self-match against new opposite-side creates. ## On-grid size allowance diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index a099be5..2bd0aea 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -44,10 +44,11 @@ export interface OrderExecutorConfig { * * Stale-order detection (limit LOB + USD allowance): a resting buy is stale * iff its price is below `worstDesiredBid − staleBandAllowance`; a resting - * sell is stale iff above `worstDesiredAsk + staleBandAllowance`. Orders - * inside that keep zone (including better-than-grid leftovers) are kept. - * On-grid size is reconciled (reduce / top-up) only when the size delta - * exceeds the USD size allowance converted to native qty (nearest unit). + * sell is stale iff above `worstDesiredAsk + staleBandAllowance`. Slightly + * worse leftovers inside that zone are kept. Better-than-grid leftovers and + * any resting order that would lock/cross the desired opposite BBO are + * cancelled (self-match prevention). On-grid size is reconciled (reduce / + * top-up) only when the size delta exceeds the USD size allowance. */ export class OrderExecutor { readonly stats = { ordersPlaced: 0, ordersCancelled: 0, reconcileCount: 0 }; @@ -265,10 +266,12 @@ export class OrderExecutor { /** * Cancel / reduce targets against `desired`: * - outside the keep zone (worst desired ± staleBandAllowance) → cancel + * - better-than-grid leftovers → cancel (avoid self-match on new creates) + * - resting orders that lock/cross the desired opposite BBO → cancel * - at desired prices with excess notional above threshold: reduce the * trailing order in place when possible (FIFO kept); cancel whole * trailing orders otherwise - * - better leftovers / within-allowance off-grid → keep + * - within-allowance worse off-grid → keep */ private findStaleOrders(desired: OrderIntent[]): { cancels: OwnOrder[]; @@ -276,6 +279,8 @@ export class OrderExecutor { } { let worstDesiredBid: bigint | undefined; let worstDesiredAsk: bigint | undefined; + let bestDesiredBid: bigint | undefined; + let bestDesiredAsk: bigint | undefined; const desiredSize = new Map(); for (const i of desired) { const k = keyOf(i.side, i.price); @@ -284,8 +289,16 @@ export class OrderExecutor { if (worstDesiredBid === undefined || i.price < worstDesiredBid) { worstDesiredBid = i.price; } - } else if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) { - worstDesiredAsk = i.price; + if (bestDesiredBid === undefined || i.price > bestDesiredBid) { + bestDesiredBid = i.price; + } + } else { + if (worstDesiredAsk === undefined || i.price > worstDesiredAsk) { + worstDesiredAsk = i.price; + } + if (bestDesiredAsk === undefined || i.price < bestDesiredAsk) { + bestDesiredAsk = i.price; + } } } @@ -304,12 +317,27 @@ export class OrderExecutor { cancels.push(order); continue; } + // Better-than-grid or would lock/cross desired asks → cancel (STP). + if ( + (bestDesiredBid !== undefined && order.price > bestDesiredBid) || + (bestDesiredAsk !== undefined && order.price >= bestDesiredAsk) + ) { + cancels.push(order); + continue; + } } else if ( worstDesiredAsk === undefined || order.price > worstDesiredAsk + allowance ) { cancels.push(order); continue; + } else if ( + (bestDesiredAsk !== undefined && order.price < bestDesiredAsk) || + (bestDesiredBid !== undefined && order.price <= bestDesiredBid) + ) { + // Better-than-grid ask or would lock/cross desired bids → cancel. + cancels.push(order); + continue; } const k = keyOf(order.side, order.price); diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index 28ad35b..3651508 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -163,6 +163,9 @@ export class Quoter { } } + // Guaranteed one-tick wide book after rounding (0-spread can lock bid==ask). + this.widenLockedBook(intents); + this.logger.debug( { strategy: this.cfg.pricing.strategy, @@ -176,6 +179,29 @@ export class Quoter { return intents; } + /** If best bid/ask lock or cross after tick rounding, bump the best ask by one tick. */ + private widenLockedBook(intents: OrderIntent[]): void { + if (this.tick === 0n || intents.length === 0) return; + let bestBid: bigint | undefined; + let bestAsk: bigint | undefined; + let bestAskIdx = -1; + for (let i = 0; i < intents.length; i++) { + const intent = intents[i]; + if (intent.side === "buy") { + if (bestBid === undefined || intent.price > bestBid) bestBid = intent.price; + } else if (bestAsk === undefined || intent.price < bestAsk) { + bestAsk = intent.price; + bestAskIdx = i; + } + } + if (bestBid === undefined || bestAsk === undefined || bestAskIdx < 0) return; + if (bestBid < bestAsk) return; + intents[bestAskIdx] = { + ...intents[bestAskIdx], + price: bestBid + this.tick, + }; + } + private computeSizes(): bigint[] { const s = this.cfg.sizing; const base = scaleBaseQuantity(s.baseQuantity, this.sizeScale); diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 1c38f8d..624390e 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -208,21 +208,38 @@ describe("OrderExecutor requote guards (regression)", () => { ); }); - it("keeps better-than-grid leftovers (does not cancel them as stale)", async () => { + it("cancels better-than-grid leftovers to prevent self-matches", async () => { const deps = makeDeps(); const executor = makeExecutor(deps); seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); - seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid - seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask + seedOrder(deps.book, 3, "buy", 99_000_000n, 1_000_000n); // better bid → would cross new asks + seedOrder(deps.book, 4, "sell", 94_000_000n, 1_000_000n); // better ask → would cross new bids await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); - assert.equal(deps.cancelledOrderIds.length, 0, "better leftovers kept"); + assert.equal(deps.cancelledOrderIds.length, 2, "better leftovers cancelled"); + assert.ok(deps.cancelledOrderIds.includes(makeOrderId(3))); + assert.ok(deps.cancelledOrderIds.includes(makeOrderId(4))); assert.equal(deps.placedIntents.length, 0); }); + it("cancels a resting bid that locks the desired best ask", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + // On-grid sizes already present; leftover bid equals desired ask → lock. + seedOrder(deps.book, 1, "buy", 95_000_000n, 1_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n, 1_000_000n); + seedOrder(deps.book, 3, "buy", 96_000_000n, 500_000n); + + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + assert.ok(planned); + assert.equal(planned.cancels.length, 1); + assert.equal(planned.cancels[0].orderId, makeOrderId(3)); + assert.equal(planned.creates.length, 0); + }); + /** * Sanity: when the book already matches the desired quotes exactly, * no reconciliation work should happen. @@ -554,12 +571,12 @@ describe("OrderExecutor band + size allowance integration", () => { assert.equal(planned.reduces.length, 0, "no size trim on this slide"); }); - it("keeps in-band leftover while downsizing on-grid excess above size allowance", () => { + it("downsizes on-grid excess and cancels better leftovers that would self-match", () => { const deps = makeDeps(); const executor = makeExecutor(deps); // On-grid bid with large excess (>$50) → reduce. seedOrder(deps.book, 1, "buy", 95_000_000n, 1_600_000n); - // Better leftover bid — inside band, off-grid → keep (not trimmed for size). + // Better leftover bid — would cross desired asks → cancel. seedOrder(deps.book, 2, "buy", 99_000_000n, 1_000_000n); seedOrder(deps.book, 3, "sell", 96_000_000n, 1_000_000n); @@ -571,7 +588,8 @@ describe("OrderExecutor band + size allowance integration", () => { assert.equal(planned.reduces.length, 1); assert.equal(planned.reduces[0].orderId, makeOrderId(1)); assert.equal(planned.reduces[0].newSize, 1_000_000n); - assert.equal(planned.cancels.length, 0, "better leftover must not be cancelled"); + assert.equal(planned.cancels.length, 1, "better leftover cancelled"); + assert.equal(planned.cancels[0].orderId, makeOrderId(2)); assert.equal(planned.creates.length, 0); }); From 9dc4d6fc43b24bc2afa01a8af92b9ca508426bff Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 27 Jul 2026 21:14:27 +0200 Subject: [PATCH 118/180] =?UTF-8?q?feat(keeper,mm):=20drop=20oracle=20unit?= =?UTF-8?q?=20rebase=20for=201=20PH/s=C2=B7day=20HashpriceUSD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match venue getMarketPrice (decimal scale only) and switch contracts codegen to hardhat-viem-abi. Co-authored-by: Cursor --- contracts/hardhat.config.ts | 4 +- contracts/package.json | 8 +- contracts/plugins/codegen/compile-action.ts | 13 -- contracts/plugins/codegen/export-abi.ts | 161 ------------------ contracts/plugins/codegen/index.ts | 31 ---- contracts/plugins/codegen/type-extensions.ts | 18 -- contracts/pnpm-lock.yaml | 16 ++ keeper/README.md | 2 +- keeper/src/oracle/priceFeed.ts | 43 +---- keeper/tests/integration/deployStack.ts | 35 ++-- keeper/tests/integration/scenarios.ts | 28 +-- keeper/tests/oracle/priceFeed.test.ts | 6 - keeper/tests/predict/coordinator.test.ts | 5 - .../tests/predict/coordinatorAlerts.test.ts | 5 - market-maker/src/adapters/futures/venue.ts | 14 +- market-maker/src/adapters/perps/venue.ts | 35 ++-- market-maker/src/core/rawOracle.ts | 20 +-- 17 files changed, 70 insertions(+), 374 deletions(-) delete mode 100644 contracts/plugins/codegen/compile-action.ts delete mode 100644 contracts/plugins/codegen/export-abi.ts delete mode 100644 contracts/plugins/codegen/index.ts delete mode 100644 contracts/plugins/codegen/type-extensions.ts diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index f390b5a..55e6394 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -1,13 +1,13 @@ import { configVariable, defineConfig } from "hardhat/config"; import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem"; -import codegenPlugin from "./plugins/codegen/index.ts"; +import hardhatViemAbi from "hardhat-viem-abi"; import { tryLoadEnvFile } from "./lib/env.ts"; tryLoadEnvFile("./../.env"); tryLoadEnvFile(".env"); export default defineConfig({ - plugins: [hardhatToolboxViem, codegenPlugin], + plugins: [hardhatToolboxViem, hardhatViemAbi], codegen: { contracts: [ "CollateralVault", diff --git a/contracts/package.json b/contracts/package.json index c2ed643..fdc8968 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,6 +27,7 @@ "@types/node": "^22.0.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "hardhat": "^3.9.1", + "hardhat-viem-abi": "github:lsheva/hardhat-viem-abi#v1.0.0-alpha.1&path:packages/hardhat-viem-abi", "typescript": "^5.8.0" }, "dependencies": { @@ -35,5 +36,10 @@ "dotenv": "^16.4.1", "viem": "^2.52.2" }, - "packageManager": "pnpm@10.28.1" + "packageManager": "pnpm@10.28.1", + "pnpm": { + "onlyBuiltDependencies": [ + "hardhat-viem-abi" + ] + } } \ No newline at end of file diff --git a/contracts/plugins/codegen/compile-action.ts b/contracts/plugins/codegen/compile-action.ts deleted file mode 100644 index d005d43..0000000 --- a/contracts/plugins/codegen/compile-action.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { HardhatRuntimeEnvironment } from "hardhat/types/hre"; -import type { TaskArguments } from "hardhat/types/tasks"; - -export default async function ( - args: TaskArguments, - hre: HardhatRuntimeEnvironment, - runSuper: (args: TaskArguments) => Promise, -): Promise { - await runSuper(args); - const { main } = await import("./export-abi.ts"); - const { contracts } = hre.config.codegen; - main(contracts.length > 0 ? contracts : undefined); -} diff --git a/contracts/plugins/codegen/export-abi.ts b/contracts/plugins/codegen/export-abi.ts deleted file mode 100644 index 5e26fd0..0000000 --- a/contracts/plugins/codegen/export-abi.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 1. Emits `abi/.ts` with `export const Abi = … as const` and - * `abi/.json` with the raw ABI array from Hardhat artifacts. - * 2. Collects unique Solidity `error` ABI items (+ `Error` / `Panic` builtins) into - * `abi/ContractErrors.json` and `abi/ContractErrors.ts`. - * - * Replaces hardhat-abi-exporter for Hardhat v3. - */ -import { mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync } from "node:fs"; -import { basename, dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { Abi } from "viem"; -import { toFunctionSelector } from "viem"; -import { formatAbiItem } from "viem/utils"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, "../.."); -const ARTIFACTS_DIR = resolve(REPO_ROOT, "artifacts"); -const OUT_DIR = resolve(REPO_ROOT, "abi"); -const OUT_ERRORS_TS = join(OUT_DIR, "ContractErrors.ts"); -const OUT_ERRORS_JSON = join(OUT_DIR, "ContractErrors.json"); - -function main(contracts?: string[]): void { - // Clear abi directory - rmSync(OUT_DIR, { recursive: true, force: true }); - - const bucket = new Map(); - - function add(err: AbiError, file: string): void { - const abi = toJsonAbiError(err); - const signature = formatAbiItem(abi); - const cur = bucket.get(signature); - if (!cur) { - bucket.set(signature, { - selector: errorSelector(abi), - signature, - abi, - files: [file], - }); - return; - } - if (!cur.files.includes(file)) { - cur.files.push(file); - } - } - - for (const builtin of BUILTIN) { - add(builtin, "(builtin)"); - } - - mkdirSync(OUT_DIR, { recursive: true }); - - for (const rel of listContractArtifactJson(contracts)) { - const src = resolve(ARTIFACTS_DIR, rel); - const name = basename(rel, ".json"); - if (!name) { - console.warn(` skipped ${rel} (no name)`); - continue; - } - try { - const raw = readFileSync(src, "utf-8"); - const artifact = JSON.parse(raw) as { abi?: unknown }; - if (!Array.isArray(artifact.abi)) { - console.warn(` skipped ${name} (no contract ABI)`); - continue; - } - - const abi = artifact.abi as Abi; - const outName = `${name}.ts`; - const dest = resolve(OUT_DIR, outName); - writeFileSync( - dest, - `export const ${name}Abi = ${JSON.stringify(artifact.abi, null, 2)} as const;\n`, - ); - writeFileSync(resolve(OUT_DIR, `${name}.json`), JSON.stringify(artifact.abi, null, 2) + "\n"); - console.log(` exported ${name}`); - - for (const item of abi) { - if (item.type !== "error") { - continue; - } - add(item as AbiError, outName); - } - } catch { - console.warn(` skipped ${name} (read/parse failed)`); - } - } - - const rows = [...bucket.values()].sort((a, b) => a.selector.localeCompare(b.selector)); - const outAbi = rows.map((r) => r.abi); - - writeFileSync( - OUT_ERRORS_TS, - `export const contractErrors = ${JSON.stringify(outAbi, null, 2)} as const;\n`, - "utf-8", - ); - writeFileSync(OUT_ERRORS_JSON, JSON.stringify(outAbi, null, 2) + "\n", "utf-8"); - - console.log(`contract errors: ${rows.length} unique → ${relative(REPO_ROOT, OUT_ERRORS_TS)}`); - console.log(""); - for (const r of rows) { - console.log(`${r.signature} - ${r.selector}`); - } -} - -/** All .json under artifacts except build-info, optionally filtered by contract name patterns. */ -function listContractArtifactJson(contracts?: string[]): string[] { - const relativePaths = readdirSync(ARTIFACTS_DIR, { recursive: true }) as string[]; - return relativePaths.filter((rel) => { - if (!rel.endsWith(".json") || rel.split(/[/\\]/).includes("build-info")) return false; - if (!contracts) return true; - const name = basename(rel, ".json"); - return contracts.some((pattern) => - pattern.includes("*") - ? new RegExp(`^${pattern.replace(/\*/g, ".*")}$`).test(name) - : name === pattern, - ); - }); -} - -type AbiError = { - inputs: { internalType: string; name: string; type: string }[]; - name: string; - type: "error"; -}; - -const BUILTIN: AbiError[] = [ - { - inputs: [{ internalType: "string", name: "message", type: "string" }], - name: "Error", - type: "error", - }, - { - inputs: [{ internalType: "uint256", name: "code", type: "uint256" }], - name: "Panic", - type: "error", - }, -]; - -function errorSelector(item: AbiError): `0x${string}` { - return toFunctionSelector({ - type: "function", - name: item.name, - inputs: item.inputs, - outputs: [], - stateMutability: "nonpayable", - }); -} - -function toJsonAbiError(item: AbiError): AbiError { - return JSON.parse(JSON.stringify(item)) as AbiError; -} - -type Accum = { - selector: `0x${string}`; - signature: string; - abi: AbiError; - files: string[]; -}; - -export { main }; diff --git a/contracts/plugins/codegen/index.ts b/contracts/plugins/codegen/index.ts deleted file mode 100644 index 299bad0..0000000 --- a/contracts/plugins/codegen/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { overrideTask } from "hardhat/config"; -import type { HardhatUserConfig, HardhatConfig } from "hardhat/types/config"; -import type { ConfigurationVariableResolver } from "hardhat/types/config"; -import type { HardhatPlugin } from "hardhat/types/plugins"; -import "./type-extensions.ts"; - -const codegenPlugin: HardhatPlugin = { - id: "codegen-after-compile", - hookHandlers: { - config: async () => ({ - default: async () => ({ - resolveUserConfig: async ( - userConfig: HardhatUserConfig, - resolveConfigVar: ConfigurationVariableResolver, - next: (u: HardhatUserConfig, r: ConfigurationVariableResolver) => Promise, - ) => { - const resolved = await next(userConfig, resolveConfigVar); - resolved.codegen = { contracts: userConfig.codegen?.contracts ?? [] }; - return resolved; - }, - }), - }), - }, - tasks: [ - overrideTask(["compile"]) - .setAction(() => import("./compile-action.ts")) - .build(), - ], -}; - -export default codegenPlugin; diff --git a/contracts/plugins/codegen/type-extensions.ts b/contracts/plugins/codegen/type-extensions.ts deleted file mode 100644 index fa305c1..0000000 --- a/contracts/plugins/codegen/type-extensions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { StringWithArtifactContractNamesAutocompletion } from "hardhat/types/artifacts"; - -declare module "hardhat/types/config" { - interface HardhatUserConfig { - codegen?: { - /** Contract names (exact or glob) to emit ABI files for. Exports all if omitted. */ - contracts?: StringWithArtifactContractNamesAutocompletion[]; - }; - } - - interface HardhatConfig { - codegen: { - contracts: string[]; - }; - } -} - -export {}; diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index b225172..60462c7 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: hardhat: specifier: ^3.9.1 version: 3.9.1 + hardhat-viem-abi: + specifier: github:lsheva/hardhat-viem-abi#v1.0.0-alpha.1&path:packages/hardhat-viem-abi + version: git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -751,6 +754,14 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + hardhat-viem-abi@git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi: + resolution: {commit: 194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9, path: packages/hardhat-viem-abi, repo: git@github.com:lsheva/hardhat-viem-abi.git, type: git} + version: 1.0.0-alpha.1 + engines: {node: '>=22'} + peerDependencies: + hardhat: ^3.0.0 + viem: ^2.0.0 + hardhat@3.9.1: resolution: {integrity: sha512-yg+0oH5tWqdsxITh6fAJjAWOSHOkC2VPlsJDJwoifs2QS1t7kyRciMy5O2F846qzH+4iqRn1rbv/5voykX3RSQ==} hasBin: true @@ -1695,6 +1706,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + hardhat-viem-abi@git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): + dependencies: + hardhat: 3.9.1 + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) + hardhat@3.9.1: dependencies: '@nomicfoundation/edr': 0.12.1 diff --git a/keeper/README.md b/keeper/README.md index 029b7d0..a1c3b14 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -223,7 +223,7 @@ Suites cover: - `discovery/tracker` — checksum dedupe, `onAdded` / `onChanged` listeners, startup backfill - `discovery/webhook` — payload extraction across `data` / `records` / array shapes - `runtime/scheduler` — alert ladder thresholds, queue upsert + executor kick wiring -- `oracle/priceFeed` — rebase to token decimals + contract-size unit (`CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY`, ×10 at defaults), dispatch, no-op on unchanged answer +- `oracle/priceFeed` — rebase to token decimals (oracle already quotes 1 PH/s·day), dispatch, no-op on unchanged answer - `predict/mm` — net delta, stress, perp/futures unrealized loss, mm/im surplus - `predict/solve` — long/short downside & upside thresholds, drag from orderMargin/funding - `predict/predictiveIndex` — upsert/invalidate, sorted crossings on rise & drop diff --git a/keeper/src/oracle/priceFeed.ts b/keeper/src/oracle/priceFeed.ts index f262744..eadff8f 100644 --- a/keeper/src/oracle/priceFeed.ts +++ b/keeper/src/oracle/priceFeed.ts @@ -1,4 +1,3 @@ -import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; @@ -39,13 +38,8 @@ export type PriceListener = (update: PriceUpdate) => void; * The feed also handles the upstream-decimals → token-decimals rebase: the * aggregator answer is `oracle.decimals()` (typically 8 for HashpriceUSD); * we rescale to the perps/futures token decimals (USDC = 6) so consumers - * compare apples to apples with `getMarketPrice()`. - * - * The oracle quotes the price of `ORACLE_UNIT_HPS_DAY` (100 TH/s over a day), but the - * venues denominate one contract in `contractSizeHpsDay` (default 1e15 = 1 PH/s over a - * day). We read that contract-size multiplier from the perps venue once at `start()` and - * apply `contractSizeHpsDay / ORACLE_UNIT_HPS_DAY` so the streamed price matches on-chain - * `getMarketPrice()`. Both venues are assumed to share the same contract size. + * compare apples to apples with `getMarketPrice()`. The oracle already quotes + * 1 PH/s per day (= venue `CONTRACT_SIZE_HPS_DAY`), so no unit rebase is applied. * * Lifecycle: * - `start()`: read decimals, prime `current` via one `latestRoundData`, @@ -60,10 +54,6 @@ export class PriceFeed { private unwatch: (() => void) | undefined; /** 10^(oracleDecimals - tokenDecimals). Set during `start()`. */ private rescaleDivisor: bigint = 1n; - /** Contract size in hashes/s·day (`contractSizeHpsDay`). Set during `start()`. */ - private contractSizeHpsDay: bigint = 1n; - /** Oracle quote basis in hashes/s·day (`ORACLE_UNIT_HPS_DAY`). Set during `start()`. */ - private oracleUnitHpsDay: bigint = 1n; private readonly chain: Chain; private readonly config: Config; @@ -102,28 +92,6 @@ export class PriceFeed { } this.rescaleDivisor = 10n ** BigInt(oracleDecimals - this.tokenDecimals); - // Rebase from the oracle's quote basis (100 TH/s/day) to one contract unit - // (contractSizeHpsDay/day), matching `getMarketPrice()` on-chain. - const [contractSizeHpsDay, oracleUnitHpsDay] = await Promise.all([ - this.chain.publicClient.readContract({ - address: this.config.perps.address, - abi: HashPowerPerpsDEXAbi, - functionName: "CONTRACT_SIZE_HPS_DAY", - }) as Promise, - this.chain.publicClient.readContract({ - address: this.config.perps.address, - abi: HashPowerPerpsDEXAbi, - functionName: "ORACLE_UNIT_HPS_DAY", - }) as Promise, - ]); - if (contractSizeHpsDay <= 0n || oracleUnitHpsDay <= 0n) { - throw new Error( - `PriceFeed: invalid contract size (contractSizeHpsDay=${contractSizeHpsDay}, ORACLE_UNIT_HPS_DAY=${oracleUnitHpsDay})`, - ); - } - this.contractSizeHpsDay = contractSizeHpsDay; - this.oracleUnitHpsDay = oracleUnitHpsDay; - await this.refresh("start"); // We watch BTC/USDC (not HashpriceUSD) because HashpriceUSD is a pure @@ -147,8 +115,6 @@ export class PriceFeed { btcUsdcFeed: this.config.oracle.btcUsdcFeedAddress, oracleDecimals, tokenDecimals: this.tokenDecimals, - contractSizeHpsDay: this.contractSizeHpsDay, - oracleUnitHpsDay: this.oracleUnitHpsDay, currentPrice: this.currentPrice, }, "PriceFeed started", @@ -199,9 +165,8 @@ export class PriceFeed { return; } - // Mirror on-chain `getMarketPrice()`: rebase decimals first, then apply the - // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). - const next = ((answer / this.rescaleDivisor) * this.contractSizeHpsDay) / this.oracleUnitHpsDay; + // Mirror on-chain `getMarketPrice()`: rebase decimals only (oracle quotes 1 PH/s/day). + const next = answer / this.rescaleDivisor; const prev = this.currentPrice; if (prev === next) return; diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 8caf98c..afb6095 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -93,17 +93,16 @@ export interface DeployedStack { tokenDecimals: number; oracleDecimals: number; /** - * Raw hashprice oracle answer (per 100 TH/s·day, i.e. `ORACLE_UNIT_HPS_DAY`). - * Both venues rebase this to a per-contract mark via - * `market = answer × CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY` (= ×10), - * so this seed is `initialMarketPrice / 10`. Fixtures that need to re-post - * the oracle (e.g. delivery settlement) write this value directly. + * Raw hashprice oracle answer (per 1 PH/s·day). Matches `initialMarketPrice` + * when oracle and token decimals align — venues apply only decimal scaling. + * Fixtures that need to re-post the oracle (e.g. delivery settlement) write + * this value directly. */ initialHashprice: bigint; /** - * Per-contract mark at deploy time (= `initialHashprice × 10`). This is the - * unit orders and positions are denominated in — scenarios use it as the - * at-the-money entry price. + * Per-contract mark at deploy time (= `initialHashprice` after decimal scale). + * This is the unit orders and positions are denominated in — scenarios use + * it as the at-the-money entry price. */ initialMarketPrice: bigint; initialBtcUsdc: bigint; @@ -124,25 +123,15 @@ const TOKEN_DECIMALS = 6; const ORACLE_DECIMALS = 6; const QUANTITY_DECIMALS = 6; -/** - * Ratio by which both venues rebase the oracle answer into a per-contract mark: - * `CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY = 1e15 / 1e14 = 10`. The oracle - * quotes 100 TH/s·day; one contract settles 1 PH/s·day, so the mark is ×10 the - * raw answer. Exported so scenarios convert market prices → oracle answers in - * one place. - */ -export const ORACLE_TO_MARKET_MULTIPLIER = 10n; - /** * Per-contract mark at deploy time. Positions and orders are denominated in this - * (contract) unit; the oracle answer is seeded at `/ ORACLE_TO_MARKET_MULTIPLIER` - * so `getMarketPrice()` (answer × 10) lands back here. Kept at $4.21 so the - * pre-existing perps fixtures (which never carried the duration factor) keep - * their dollar sizing unchanged. + * (contract) unit; the oracle answer is seeded to the same value so + * `getMarketPrice()` lands here (oracle already quotes 1 PH/s·day). Kept at + * $4.21 so pre-existing perps fixtures keep their dollar sizing unchanged. */ const INITIAL_MARKET_PRICE = parseUnits("4.21", TOKEN_DECIMALS); -/** Raw hashprice oracle answer (per 100 TH/s·day) — rebased ×10 into the mark above. */ -const INITIAL_HASHPRICE = INITIAL_MARKET_PRICE / ORACLE_TO_MARKET_MULTIPLIER; +/** Raw hashprice oracle answer (per 1 PH/s·day) — equals the mark above. */ +const INITIAL_HASHPRICE = INITIAL_MARKET_PRICE; /** Reference BTC/USDC mid-price; only the *delta* matters for predictor tests. */ const INITIAL_BTC_USDC = parseUnits("65000", ORACLE_DECIMALS); diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index a7acba7..d7af704 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -1,11 +1,6 @@ import { parseUnits, type Address } from "viem"; import { hardhat } from "viem/chains"; -import { - deployStack, - ORACLE_TO_MARKET_MULTIPLIER, - type DeployedStack, - type Wallet, -} from "./deployStack.ts"; +import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; /** * Fixture builders. @@ -36,14 +31,13 @@ import { // ───────────────────────────────────────────────────────────────────────── export interface BaseFixture extends DeployedStack { - /** Write a raw hashprice *oracle answer* (per 100 TH/s·day). */ + /** Write a raw hashprice *oracle answer* (per 1 PH/s·day). */ bumpHashprice(newPrice: bigint): Promise; bumpBtcUsdc(newPrice: bigint): Promise; /** - * Set the per-contract *mark* (`getMarketPrice()` value). Internally divides - * by `ORACLE_TO_MARKET_MULTIPLIER` (×10 rebase) before writing the oracle, so - * callers can reason in the same contract unit that orders/positions use. - * Does not touch BTC/USDC — used to stage a fixture's at-the-money entry mark. + * Set the per-contract *mark* (`getMarketPrice()` value). Writes the same + * value to the oracle (oracle already quotes 1 PH/s·day). Does not touch + * BTC/USDC — used to stage a fixture's at-the-money entry mark. */ setMark(marketPrice: bigint): Promise; /** Deposit USDC into the vault from the given (test-known) wallet. */ @@ -189,27 +183,21 @@ export interface CrossVenueOrdersAndPositionsFixture extends CrossVenueFixture { // Base fixture // ───────────────────────────────────────────────────────────────────────── -/** Convert a per-contract mark into the raw oracle answer the venues rebase ×10. */ -function markToOracle(marketPrice: bigint): bigint { - return marketPrice / ORACLE_TO_MARKET_MULTIPLIER; -} - export async function baseFixture(rpcUrl: string): Promise { const stack = await deployStack(rpcUrl); return { ...stack, bumpHashprice: (price) => writeOracle(stack, stack.addresses.hashpriceOracle, price), bumpBtcUsdc: (price) => writeOracle(stack, stack.addresses.btcUsdcFeed, price), - setMark: (marketPrice) => - writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)), + setMark: (marketPrice) => writeOracle(stack, stack.addresses.hashpriceOracle, marketPrice), deposit: (user, amount) => depositTo(stack, user, amount), crashOracles: async (marketPrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); + await writeOracle(stack, stack.addresses.hashpriceOracle, marketPrice); const movedBtc = (stack.config.initialBtcUsdc * 9n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, pumpOracles: async (marketPrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); + await writeOracle(stack, stack.addresses.hashpriceOracle, marketPrice); const movedBtc = (stack.config.initialBtcUsdc * 11n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, diff --git a/keeper/tests/oracle/priceFeed.test.ts b/keeper/tests/oracle/priceFeed.test.ts index dcf530c..c3dce82 100644 --- a/keeper/tests/oracle/priceFeed.test.ts +++ b/keeper/tests/oracle/priceFeed.test.ts @@ -46,12 +46,6 @@ function makeChainStub(initialAnswer: bigint, decimals: number): ChainStub { publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return decimals; - // Contract-size rebase reads. Returning equal values gives a 1× - // passthrough so these tests assert the pure decimals rebase without a - // contract-size multiplier (the x10 factor is exercised by the venue / - // integration paths that use the real 1e15 / 100e12 constants). - if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; - if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { reads++; return [1n, currentAnswer, 1_000n, 1_000n, 1n] as const; diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 9b9c059..9fffdbe 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -87,11 +87,6 @@ function buildHarness({ publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; - // Contract-size rebase reads (PriceFeed.start). Equal values → 1× - // passthrough, so the streamed price stays $100 and matches the - // `computePortfolioMM` price the harness derives from the same answer. - if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; - if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index a927270..b551be7 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -64,11 +64,6 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; - // Contract-size rebase reads (PriceFeed.start). Equal values → 1× - // passthrough, so the streamed price stays $100 and matches the IM/MM - // the harness derives from the same answer. - if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; - if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 814631d..fdd4d8c 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -125,7 +125,7 @@ export class FuturesVenueAdapter implements VenueAdapter { publicClient: this.publicClient, label: "futures", resolve: async () => { - const [oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay] = await this.publicClient.multicall({ + const [oracle, divisor] = await this.publicClient.multicall({ allowFailure: false, contracts: [ { @@ -138,19 +138,9 @@ export class FuturesVenueAdapter implements VenueAdapter { abi: FuturesAbi, functionName: "hashpriceScalingDivisor", }, - { - address: this.address, - abi: FuturesAbi, - functionName: "CONTRACT_SIZE_HPS_DAY", - }, - { - address: this.address, - abi: FuturesAbi, - functionName: "ORACLE_UNIT_HPS_DAY", - }, ], }); - return { oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay }; + return { oracle, divisor }; }, }); } diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index ec0e4bf..8df2079 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -109,28 +109,17 @@ export class PerpsVenueAdapter implements VenueAdapter { abi: HashPowerPerpsDEXAbi, functionName: "priceOracle", }); - const [oracleDecimals, tokenDecimals, contractSizeHpsDay, oracleUnitHpsDay] = - await this.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: oracle, - abi: chainlinkAggregatorAbi, - functionName: "decimals", - }, - { address: token, abi: erc20Abi, functionName: "decimals" }, - { - address: this.address, - abi: HashPowerPerpsDEXAbi, - functionName: "CONTRACT_SIZE_HPS_DAY", - }, - { - address: this.address, - abi: HashPowerPerpsDEXAbi, - functionName: "ORACLE_UNIT_HPS_DAY", - }, - ], - }); + const [oracleDecimals, tokenDecimals] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { + address: oracle, + abi: chainlinkAggregatorAbi, + functionName: "decimals", + }, + { address: token, abi: erc20Abi, functionName: "decimals" }, + ], + }); if (tokenDecimals > oracleDecimals) { throw new Error( `perps: tokenDecimals (${tokenDecimals}) > oracleDecimals (${oracleDecimals})`, @@ -139,8 +128,6 @@ export class PerpsVenueAdapter implements VenueAdapter { return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), - contractSizeHpsDay, - oracleUnitHpsDay, }; }, }); diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts index ff3421d..d345b9f 100644 --- a/market-maker/src/core/rawOracle.ts +++ b/market-maker/src/core/rawOracle.ts @@ -7,17 +7,16 @@ * 2-tick floor on the symmetric bid/ask layout. * * `RawOracleReader` reads the underlying Chainlink aggregator directly and - * applies the same `10^(oracle.decimals − token.decimals)` rebase AND the same - * contract-size multiplier (`contractSizeHpsDay / ORACLE_UNIT_HPS_DAY`) the venue does, + * applies the same `10^(oracle.decimals − token.decimals)` rebase the venue does, * but skips the tick rounding. The MM gets a unit-precision mid that lands * between ticks ~99% of the time, so `roundDownToTick(r) → bidMid` and * `roundUpToTick(r) → askMid` produce a 1-tick spread without any extra * pricing-strategy plumbing. * - * The two venues differ only in *how* the (oracle address, scaling divisor, - * contract-size multiplier) tuple is discovered. Each adapter supplies that as a - * `resolve()` callback; the reader caches the result for the lifetime of the - * process (all three change only on `setOracle`/`setContractSize`-style admin txs). + * The two venues differ only in *how* the (oracle address, scaling divisor) + * tuple is discovered. Each adapter supplies that as a `resolve()` callback; + * the reader caches the result for the lifetime of the process (both change + * only on `setOracle`-style admin txs). */ import type { PublicClient } from "viem"; @@ -50,10 +49,6 @@ export interface RawOracleConfig { oracle: `0x${string}`; /** 10^(oracle.decimals − token.decimals); used to rebase the answer to token decimals. */ divisor: bigint; - /** Contract size in hashes/s·day (`contractSizeHpsDay`). Numerator of the unit rebase. */ - contractSizeHpsDay: bigint; - /** The oracle's quote basis in hashes/s·day (`ORACLE_UNIT_HPS_DAY`). Denominator of the unit rebase. */ - oracleUnitHpsDay: bigint; } export class RawOracleReader { @@ -88,9 +83,8 @@ export class RawOracleReader { if (answer <= 0n) { throw new Error(`${this.label}: oracle returned non-positive answer (${answer.toString()})`); } - // Mirror the venue's `getMarketPrice()`: rebase decimals first, then apply the - // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). - return ((answer / this.cache.divisor) * this.cache.contractSizeHpsDay) / this.cache.oracleUnitHpsDay; + // Mirror the venue's `getMarketPrice()` decimal rebase (no unit factor). + return answer / this.cache.divisor; } /** Drop cached (oracle, divisor) — next `read()` will re-resolve. */ From 166215eee288d708ce04d83f68e6077ce12acba8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 27 Jul 2026 21:23:44 +0200 Subject: [PATCH 119/180] fix(ci): install hardhat-viem-abi via HTTPS tarball Use a public HTTPS dependency URL so GitHub Actions does not need SSH access to the plugin repo. Co-authored-by: Cursor --- contracts/package.json | 2 +- contracts/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/package.json b/contracts/package.json index fdc8968..6069764 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,7 +27,7 @@ "@types/node": "^22.0.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "hardhat": "^3.9.1", - "hardhat-viem-abi": "github:lsheva/hardhat-viem-abi#v1.0.0-alpha.1&path:packages/hardhat-viem-abi", + "hardhat-viem-abi": "https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.1&path:packages/hardhat-viem-abi", "typescript": "^5.8.0" }, "dependencies": { diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 60462c7..8f9c3ae 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -43,8 +43,8 @@ importers: specifier: ^3.9.1 version: 3.9.1 hardhat-viem-abi: - specifier: github:lsheva/hardhat-viem-abi#v1.0.0-alpha.1&path:packages/hardhat-viem-abi - version: git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + specifier: https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.1&path:packages/hardhat-viem-abi + version: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -754,8 +754,8 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - hardhat-viem-abi@git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi: - resolution: {commit: 194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9, path: packages/hardhat-viem-abi, repo: git@github.com:lsheva/hardhat-viem-abi.git, type: git} + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi: + resolution: {path: packages/hardhat-viem-abi, tarball: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9} version: 1.0.0-alpha.1 engines: {node: '>=22'} peerDependencies: @@ -1706,7 +1706,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - hardhat-viem-abi@git+https://git@github.com:lsheva/hardhat-viem-abi.git#194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9&path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): dependencies: hardhat: 3.9.1 viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) From b424cde29c0f5bc78c50dbb00cfbbc4cc9aeb4cc Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:20:03 -0400 Subject: [PATCH 120/180] feat: add @hashpower/collateral-abi npm package with auto-publish pipeline Publishes viem-ready vault/margin-engine/points ABIs plus a testnet/mainnet deployments manifest (addresses verified on-chain). CI computes the semver bump by diffing the ABI surface against the last published version and publishes via npm Trusted Publishing (OIDC, tokenless) with Slack announce. v1.0.0 published manually to bootstrap the trust. Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 139 +++++++++++++++++++ collateral-abi/.gitignore | 5 + collateral-abi/README.md | 40 ++++++ collateral-abi/deployments.json | 22 +++ collateral-abi/package.json | 39 ++++++ collateral-abi/pnpm-lock.yaml | 24 ++++ collateral-abi/scripts/build.mjs | 37 +++++ collateral-abi/scripts/semver-diff.mjs | 69 +++++++++ collateral-abi/tsconfig.json | 13 ++ 9 files changed, 388 insertions(+) create mode 100644 .github/workflows/publish-collateral-abi.yml create mode 100644 collateral-abi/.gitignore create mode 100644 collateral-abi/README.md create mode 100644 collateral-abi/deployments.json create mode 100644 collateral-abi/package.json create mode 100644 collateral-abi/pnpm-lock.yaml create mode 100644 collateral-abi/scripts/build.mjs create mode 100644 collateral-abi/scripts/semver-diff.mjs create mode 100644 collateral-abi/tsconfig.json diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml new file mode 100644 index 0000000..17d52a1 --- /dev/null +++ b/.github/workflows/publish-collateral-abi.yml @@ -0,0 +1,139 @@ +name: Publish @hashpower/collateral-abi + +# Publishes the ABI package to npm whenever the generated ABIs or the +# deployments manifest change on main. Versioning is automatic and +# semver-correct: the ABI *is* the package's public API, so CI diffs the +# built ABI surface against the last published version to compute the bump +# (removed/changed entry -> major, added entry -> minor, metadata -> patch) +# and commits the new version back before publishing. +# +# Auth uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. +# NOTE: the very first release must be published manually +# (`cd collateral-abi && pnpm build && npm publish --access public`), +# then configure this repo+workflow as a Trusted Publisher in the +# package settings on npmjs.com. CI handles every release after that. + +on: + push: + branches: + # NOTE: publishing off `dev` while the repo operates there. + # At GA, change this to `main` (and update the `npm-publish` + # environment's deployment-branch rule to match) so npm only + # updates on mainline releases. + - dev + paths: + - "contracts/abi/**" + - "collateral-abi/**" + - ".github/workflows/publish-collateral-abi.yml" + # Manual runs from any branch for testing the pipeline + workflow_dispatch: + +permissions: + contents: write # push the automated version bump + id-token: write # npm provenance / trusted publishing + +concurrency: + group: publish-collateral-abi + cancel-in-progress: false + +jobs: + publish: + name: 📦 Build & publish to npm + runs-on: ubuntu-latest + # Must match the Trusted Publisher environment configured on npmjs.com + environment: npm-publish + defaults: + run: + working-directory: ./collateral-abi + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: collateral-abi/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + cache-dependency-path: collateral-abi/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package + run: pnpm build + + - name: Compute semver bump from ABI diff + id: bump + run: | + PKG=$(node -p "require('./package.json').name") + PUBLISHED=$(npm view "$PKG" version 2>/dev/null || echo "none") + + if [ "$PUBLISHED" = "none" ]; then + echo "First publish — using version from package.json as-is" + echo "level=first" >> "$GITHUB_OUTPUT" + echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Fetch the published tarball and diff its ABI surface against the fresh build + TARBALL=$(npm pack "$PKG@$PUBLISHED" --silent | tail -1) + mkdir -p /tmp/published + tar -xzf "$TARBALL" -C /tmp/published + rm "$TARBALL" + + LEVEL=$(node scripts/semver-diff.mjs /tmp/published/package .) + echo "Published: $PUBLISHED — ABI diff requires: $LEVEL" + echo "level=$LEVEL" >> "$GITHUB_OUTPUT" + + if [ "$LEVEL" = "none" ]; then + echo "No ABI or metadata changes — skipping publish" + echo "version=$PUBLISHED" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Baseline on the published version, then apply the computed bump + npm version "$PUBLISHED" --no-git-tag-version --allow-same-version + npm version "$LEVEL" --no-git-tag-version + NEW=$(node -p "require('./package.json').version") + + if ! git diff --quiet -- package.json; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + git commit -m "chore(collateral-abi): v$NEW ($LEVEL) [skip ci]" + git push + fi + echo "version=$NEW" >> "$GITHUB_OUTPUT" + echo "Version: $NEW ($LEVEL bump from $PUBLISHED)" + + - name: Publish + if: steps.bump.outputs.level != 'none' + run: npm publish --access public --provenance + + - name: Summary + if: steps.bump.outputs.level != 'none' + run: | + PKG=$(node -p "require('./package.json').name") + VERSION=$(node -p "require('./package.json').version") + echo "## 📦 Published $PKG@$VERSION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "https://www.npmjs.com/package/$PKG/v/$VERSION" >> $GITHUB_STEP_SUMMARY + + - name: Send Slack notification + if: always() && steps.bump.outputs.level != 'none' && steps.bump.outputs.level != '' + uses: ./.github/actions/slack-notify + with: + status: ${{ job.status }} + environment: "npm" + service_name: "@hashpower/collateral-abi" + version: ${{ steps.bump.outputs.version || 'unknown' }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + additional_info: "${{ format('*Semver bump:* `{0}` • *Package:* ', steps.bump.outputs.level, steps.bump.outputs.version) }}" diff --git a/collateral-abi/.gitignore b/collateral-abi/.gitignore new file mode 100644 index 0000000..3fc495e --- /dev/null +++ b/collateral-abi/.gitignore @@ -0,0 +1,5 @@ +# Generated by scripts/build.mjs — source of truth is ../contracts/abi +src/ +dist/ +json/ +node_modules/ diff --git a/collateral-abi/README.md b/collateral-abi/README.md new file mode 100644 index 0000000..c6fb5ca --- /dev/null +++ b/collateral-abi/README.md @@ -0,0 +1,40 @@ +# @hashpower/collateral-abi + +ABIs and deployment addresses for the Hashpower unified collateral system on Base: + +| Contract | Purpose | +| --- | --- | +| `CollateralVault` | Shared USDC custody — `deposit`, `withdraw` (IM-gated), `balanceOf` | +| `PortfolioMarginEngine` | Cross-product portfolio margin — `computePortfolioIM/MM`, `isHealthy`, `canPlaceOrder` | +| `Points` | Rewards ledger | + +Collateral is unified across all Hashpower trading venues (futures, perps): deposit once to the vault, trade everywhere. Withdrawals are gated by portfolio initial margin. + +## Usage + +```ts +import { CollateralVaultAbi, PortfolioMarginEngineAbi } from "@hashpower/collateral-abi"; +import deployments from "@hashpower/collateral-abi/deployments.json" with { type: "json" }; + +// "testnet" (Base Sepolia) or "mainnet" (Base) +const env = process.env.HASHPOWER_ENV ?? "testnet"; +const { contracts } = deployments.environments[env]; + +const im = await client.readContract({ + address: contracts.PortfolioMarginEngine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioIM", + args: [account], +}); +``` + +Raw JSON ABIs (for subgraphs and non-TypeScript consumers) are available under `@hashpower/collateral-abi/json/.json`. + +## How this package is built + +Contents are generated — do not edit by hand: + +- `src/` is copied from `../contracts/abi` (the Hardhat codegen output) by `scripts/build.mjs`, then compiled to `dist/`. +- `deployments.json` is the canonical address manifest for this repo; it is updated when contracts are (re)deployed. + +Publishing happens automatically from CI when ABIs or the manifest change (see `.github/workflows/publish-collateral-abi.yml`). diff --git a/collateral-abi/deployments.json b/collateral-abi/deployments.json new file mode 100644 index 0000000..e12561f --- /dev/null +++ b/collateral-abi/deployments.json @@ -0,0 +1,22 @@ +{ + "package": "@hashpower/collateral-abi", + "environments": { + "testnet": { + "chainId": 84532, + "network": "base-sepolia", + "contracts": { + "CollateralVault": "0x54A79e2a5C60ACe37b280eBbCda51b4E903d25F0", + "PortfolioMarginEngine": "0x3899e429Ef47140eC46c6E23F04253C24F221b69", + "Points": "0x153F6cb4386d717AD94791E6Ee8ae37f80315972", + "CollateralToken": "0xDd15eED84065A58c9E9ff9E95fb996be0fff22AA" + }, + "subgraphs": {} + }, + "mainnet": { + "chainId": 8453, + "network": "base", + "contracts": {}, + "subgraphs": {} + } + } +} diff --git a/collateral-abi/package.json b/collateral-abi/package.json new file mode 100644 index 0000000..604d7f5 --- /dev/null +++ b/collateral-abi/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hashpower/collateral-abi", + "version": "1.0.0", + "description": "ABIs and deployment addresses for the Hashpower unified collateral system (CollateralVault, PortfolioMarginEngine, Points) on Base", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/Lumerin-protocol/collateral-margin.git", + "directory": "collateral-abi" + }, + "keywords": ["hashpower", "collateral", "margin", "vault", "abi", "base", "viem"], + "files": ["dist", "json", "deployments.json", "README.md"], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./deployments.json": "./deployments.json", + "./json/*.json": "./json/*.json" + }, + "scripts": { + "build": "node scripts/build.mjs && tsc -p tsconfig.json", + "clean": "rm -rf src dist json", + "prepublishOnly": "pnpm build" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@10.28.1", + "publishConfig": { + "access": "public" + } +} diff --git a/collateral-abi/pnpm-lock.yaml b/collateral-abi/pnpm-lock.yaml new file mode 100644 index 0000000..eb1820e --- /dev/null +++ b/collateral-abi/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + +packages: + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + typescript@5.9.3: {} diff --git a/collateral-abi/scripts/build.mjs b/collateral-abi/scripts/build.mjs new file mode 100644 index 0000000..041b1d2 --- /dev/null +++ b/collateral-abi/scripts/build.mjs @@ -0,0 +1,37 @@ +// Copies the codegen output from contracts/abi into this package: +// *.ts -> src/ (compiled to dist/ by tsc) +// *.json -> json/ (shipped raw for non-TS consumers, e.g. subgraphs) +// and generates src/index.ts re-exporting everything. +import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const abiDir = path.resolve(pkgRoot, "../contracts/abi"); +const srcDir = path.join(pkgRoot, "src"); +const jsonDir = path.join(pkgRoot, "json"); + +// Test-only mocks are not part of the public package +const EXCLUDE = new Set([]); + +rmSync(srcDir, { recursive: true, force: true }); +rmSync(jsonDir, { recursive: true, force: true }); +mkdirSync(srcDir, { recursive: true }); +mkdirSync(jsonDir, { recursive: true }); + +const modules = []; +for (const file of readdirSync(abiDir).sort()) { + const base = file.replace(/\.(ts|json)$/, ""); + if (EXCLUDE.has(base)) continue; + if (file.endsWith(".ts")) { + copyFileSync(path.join(abiDir, file), path.join(srcDir, file)); + modules.push(base); + } else if (file.endsWith(".json")) { + copyFileSync(path.join(abiDir, file), path.join(jsonDir, file)); + } +} + +const index = modules.map((name) => `export * from "./${name}.js";`).join("\n"); +writeFileSync(path.join(srcDir, "index.ts"), `${index}\n`); + +console.log(`Copied ${modules.length} ABI modules from contracts/abi`); diff --git a/collateral-abi/scripts/semver-diff.mjs b/collateral-abi/scripts/semver-diff.mjs new file mode 100644 index 0000000..0d5f263 --- /dev/null +++ b/collateral-abi/scripts/semver-diff.mjs @@ -0,0 +1,69 @@ +// Computes the required semver bump by diffing the ABI surface of the +// last-published package against the freshly built one. +// +// Usage: node scripts/semver-diff.mjs +// Prints one of: major | minor | patch | none +// +// Rules — the ABI *is* the public API, so the level is computable: +// - ABI entry removed or modified, or a contract file removed -> major +// - New ABI entry or new contract file -> minor +// - Only metadata changed (deployments.json, README, ...) -> patch +// - Nothing changed -> none +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +const [publishedRoot, currentRoot] = process.argv.slice(2); +if (!publishedRoot || !currentRoot) { + console.error("Usage: semver-diff.mjs "); + process.exit(1); +} + +// Canonical stringify (sorted keys) so formatting differences don't matter +function canon(value) { + if (Array.isArray(value)) return `[${value.map(canon).join(",")}]`; + if (value && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(value[k])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +const abiEntries = (file) => new Set(JSON.parse(readFileSync(file, "utf8")).map(canon)); +const listJson = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json")).sort() : []); + +const oldDir = path.join(publishedRoot, "json"); +const newDir = path.join(currentRoot, "json"); +const oldFiles = listJson(oldDir); +const newFiles = listJson(newDir); + +let removedOrChanged = false; +let added = false; + +for (const file of oldFiles) { + if (!newFiles.includes(file)) { + removedOrChanged = true; + continue; + } + const oldSet = abiEntries(path.join(oldDir, file)); + const newSet = abiEntries(path.join(newDir, file)); + for (const entry of oldSet) if (!newSet.has(entry)) removedOrChanged = true; + for (const entry of newSet) if (!oldSet.has(entry)) added = true; +} +for (const file of newFiles) { + if (!oldFiles.includes(file)) added = true; +} + +if (removedOrChanged) { + console.log("major"); +} else if (added) { + console.log("minor"); +} else { + // ABI surface identical — check whether package metadata changed + const metaChanged = ["deployments.json", "README.md"].some((file) => { + const oldPath = path.join(publishedRoot, file); + const newPath = path.join(currentRoot, file); + if (!existsSync(oldPath) || !existsSync(newPath)) return true; + return readFileSync(oldPath, "utf8") !== readFileSync(newPath, "utf8"); + }); + console.log(metaChanged ? "patch" : "none"); +} diff --git a/collateral-abi/tsconfig.json b/collateral-abi/tsconfig.json new file mode 100644 index 0000000..ffca7cc --- /dev/null +++ b/collateral-abi/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"] +} From a7f184ac40dac9ea99fe1e8936d7c0cb508bffef Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:03:43 -0400 Subject: [PATCH 121/180] ci: add ABI drift check (compile-and-compare on contract changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guarantees committed ABIs in contracts/abi/ always match compiled output — the publish pipeline and semver classifier depend on them being truthful. Ported from hashprice-oracle. Co-authored-by: Cursor --- .github/workflows/check-contracts-abi.yml | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/check-contracts-abi.yml diff --git a/.github/workflows/check-contracts-abi.yml b/.github/workflows/check-contracts-abi.yml new file mode 100644 index 0000000..d20778b --- /dev/null +++ b/.github/workflows/check-contracts-abi.yml @@ -0,0 +1,86 @@ +name: Check ABI Up-to-Date + +on: + pull_request: + paths: + - "contracts/**" + - ".github/workflows/check-contracts-abi.yml" + push: + branches: + - dev + - main + paths: + - "contracts/**" + - ".github/workflows/check-contracts-abi.yml" + +jobs: + check-abi: + name: 🔍 Verify ABIs are up-to-date + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./contracts + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: contracts/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: contracts/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Compile contracts and generate ABIs + run: pnpm compile + + - name: Check for ABI differences + run: | + if [ -n "$(git diff --name-only -- abi/)" ]; then + echo "::error::Generated ABIs do not match committed ABIs. Please run 'pnpm compile' in contracts/ and commit the updated ABI files." + echo "" + echo "Changed files:" + git diff --name-only -- abi/ + echo "" + echo "Diff:" + git diff -- abi/ + exit 1 + fi + + if [ -n "$(git ls-files --others --exclude-standard -- abi/)" ]; then + echo "::error::New ABI files were generated but not committed. Please run 'pnpm compile' in contracts/ and commit the new ABI files." + echo "" + echo "Untracked files:" + git ls-files --others --exclude-standard -- abi/ + exit 1 + fi + + echo "✅ All ABIs are up-to-date." + + - name: Summary + if: failure() + run: | + echo "## ❌ ABI Check Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The committed ABI files do not match what the contracts generate." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**To fix:** Run the following locally and commit the changes:" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "cd contracts && pnpm compile" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Summary (success) + if: success() + run: | + echo "## ✅ ABI Check Passed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "All generated ABIs match the committed versions." >> $GITHUB_STEP_SUMMARY From 29b0c93ab6ba50f56b85bbcc0c04e6ca08b8cec0 Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:26:08 -0400 Subject: [PATCH 122/180] fix(ci): tag ABI releases instead of committing version bump to protected dev Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 26 +++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index 17d52a1..d6f95ee 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -5,7 +5,9 @@ name: Publish @hashpower/collateral-abi # semver-correct: the ABI *is* the package's public API, so CI diffs the # built ABI surface against the last published version to compute the bump # (removed/changed entry -> major, added entry -> minor, metadata -> patch) -# and commits the new version back before publishing. +# and publishes with that version. Nothing is committed back — the branch is +# protected — so the version of record lives on npm and each release is +# marked with a git tag (collateral-abi-vX.Y.Z) on the source commit. # # Auth uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. # NOTE: the very first release must be published manually @@ -29,7 +31,7 @@ on: workflow_dispatch: permissions: - contents: write # push the automated version bump + contents: write # push release tags id-token: write # npm provenance / trusted publishing concurrency: @@ -98,18 +100,12 @@ jobs: exit 0 fi - # Baseline on the published version, then apply the computed bump + # Baseline on the published version, then apply the computed bump. + # Working-copy only: dev is protected (PRs required) so CI never + # pushes commits. npm holds the version of record. npm version "$PUBLISHED" --no-git-tag-version --allow-same-version npm version "$LEVEL" --no-git-tag-version NEW=$(node -p "require('./package.json').version") - - if ! git diff --quiet -- package.json; then - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - git commit -m "chore(collateral-abi): v$NEW ($LEVEL) [skip ci]" - git push - fi echo "version=$NEW" >> "$GITHUB_OUTPUT" echo "Version: $NEW ($LEVEL bump from $PUBLISHED)" @@ -117,6 +113,14 @@ jobs: if: steps.bump.outputs.level != 'none' run: npm publish --access public --provenance + - name: Tag release + if: steps.bump.outputs.level != 'none' + continue-on-error: true # traceability only — never blocks a publish + run: | + TAG="collateral-abi-v${{ steps.bump.outputs.version }}" + git tag "$TAG" + git push origin "$TAG" + - name: Summary if: steps.bump.outputs.level != 'none' run: | From 4b97f641786fcbac990c57bfd43e5e2113eed8b5 Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:16:33 -0400 Subject: [PATCH 123/180] ci(collateral-abi): 0.x semver line for pre-GA + exact-version dispatch input Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 32 ++++++++++++++++++-- collateral-abi/package.json | 2 +- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index d6f95ee..21a5dca 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -5,7 +5,8 @@ name: Publish @hashpower/collateral-abi # semver-correct: the ABI *is* the package's public API, so CI diffs the # built ABI surface against the last published version to compute the bump # (removed/changed entry -> major, added entry -> minor, metadata -> patch) -# and publishes with that version. Nothing is committed back — the branch is +# — demoted while the published major is 0, per semver's 0.x convention +# (breaking -> minor, additive -> patch) — and publishes with that version. Nothing is committed back — the branch is # protected — so the version of record lives on npm and each release is # marked with a git tag (collateral-abi-vX.Y.Z) on the source commit. # @@ -27,8 +28,14 @@ on: - "contracts/abi/**" - "collateral-abi/**" - ".github/workflows/publish-collateral-abi.yml" - # Manual runs from any branch for testing the pipeline + # Manual runs from any branch for testing the pipeline. `version` publishes + # exactly that version, skipping the ABI diff (one-time resets / recovery). workflow_dispatch: + inputs: + version: + description: "Exact version to publish (skips semver diff)" + required: false + default: "" permissions: contents: write # push release tags @@ -73,7 +80,17 @@ jobs: - name: Compute semver bump from ABI diff id: bump + env: + FORCED_VERSION: ${{ inputs.version || '' }} run: | + if [ -n "$FORCED_VERSION" ]; then + npm version "$FORCED_VERSION" --no-git-tag-version --allow-same-version + echo "Forced version: $FORCED_VERSION (semver diff skipped)" + echo "level=forced" >> "$GITHUB_OUTPUT" + echo "version=$FORCED_VERSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + PKG=$(node -p "require('./package.json').name") PUBLISHED=$(npm view "$PKG" version 2>/dev/null || echo "none") @@ -92,6 +109,17 @@ jobs: LEVEL=$(node scripts/semver-diff.mjs /tmp/published/package .) echo "Published: $PUBLISHED — ABI diff requires: $LEVEL" + + # Pre-1.0 the package is explicitly unstable (semver 0.x rules), so + # demote: breaking -> minor, additive -> patch. Cutting 1.0.0 at GA + # (from main) ends the demotion automatically. + if [ "${PUBLISHED%%.*}" = "0" ]; then + case "$LEVEL" in + major) LEVEL=minor ;; + minor) LEVEL=patch ;; + esac + echo "0.x pre-release line — demoted bump to: $LEVEL" + fi echo "level=$LEVEL" >> "$GITHUB_OUTPUT" if [ "$LEVEL" = "none" ]; then diff --git a/collateral-abi/package.json b/collateral-abi/package.json index 604d7f5..76446b9 100644 --- a/collateral-abi/package.json +++ b/collateral-abi/package.json @@ -1,6 +1,6 @@ { "name": "@hashpower/collateral-abi", - "version": "1.0.0", + "version": "0.1.0", "description": "ABIs and deployment addresses for the Hashpower unified collateral system (CollateralVault, PortfolioMarginEngine, Points) on Base", "license": "MIT", "type": "module", From dcd2b828c0317fe547365d38ce82cb20b770d45e Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:34:32 -0400 Subject: [PATCH 124/180] fix(collateral-abi): semver diff only counts JSON arrays as ABI files Co-authored-by: Cursor --- collateral-abi/scripts/semver-diff.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/collateral-abi/scripts/semver-diff.mjs b/collateral-abi/scripts/semver-diff.mjs index 0d5f263..8000a18 100644 --- a/collateral-abi/scripts/semver-diff.mjs +++ b/collateral-abi/scripts/semver-diff.mjs @@ -29,7 +29,18 @@ function canon(value) { } const abiEntries = (file) => new Set(JSON.parse(readFileSync(file, "utf8")).map(canon)); -const listJson = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json")).sort() : []); + +// Only ABI files (JSON arrays) count toward the diff; stray manifests like a +// codegen-emitted package.json are ignored on both sides. +const isAbiFile = (dir, f) => { + try { + return Array.isArray(JSON.parse(readFileSync(path.join(dir, f), "utf8"))); + } catch { + return false; + } +}; +const listJson = (dir) => + existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json") && isAbiFile(dir, f)).sort() : []; const oldDir = path.join(publishedRoot, "json"); const newDir = path.join(currentRoot, "json"); From bde0c3977eaea78e2856d36c31523a2d7c9d6c93 Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:36:41 -0400 Subject: [PATCH 125/180] ci(collateral-abi): publish with explicit --tag latest to allow version resets Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index 21a5dca..676ad41 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -139,7 +139,9 @@ jobs: - name: Publish if: steps.bump.outputs.level != 'none' - run: npm publish --access public --provenance + # --tag latest is explicit so a forced lower version (reset/rollback) + # can still take the latest tag; npm forbids that implicitly. + run: npm publish --access public --provenance --tag latest - name: Tag release if: steps.bump.outputs.level != 'none' From a59eb933ce784f073618c7f41cbf2376a44c5fe9 Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:31:13 -0400 Subject: [PATCH 126/180] ci(collateral-abi): dispatch hashpower.io rebuild after publish Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index 676ad41..50c1599 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -151,6 +151,23 @@ jobs: git tag "$TAG" git push origin "$TAG" + - name: Trigger hashpower.io rebuild + if: steps.bump.outputs.level != 'none' + continue-on-error: true # site refresh is best-effort — never blocks a publish + env: + DISPATCH_TOKEN: ${{ secrets.HASHPOWER_IO_DISPATCH_TOKEN }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::warning::HASHPOWER_IO_DISPATCH_TOKEN is not set — hashpower.io will not auto-refresh /deployments.json and /build. Add a fine-grained PAT (hashpower-io repo, Contents: read/write) as an org or repo secret." + exit 0 + fi + curl -sf -X POST \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/Lumerin-protocol/hashpower-io/dispatches \ + -d "{\"event_type\":\"abi-published\",\"client_payload\":{\"package\":\"@hashpower/collateral-abi\",\"version\":\"${{ steps.bump.outputs.version }}\",\"source_ref\":\"${{ github.ref_name }}\"}}" + echo "Dispatched abi-published to Lumerin-protocol/hashpower-io" + - name: Summary if: steps.bump.outputs.level != 'none' run: | From 5f855199bd754784961864db36b05cd9d3632ef8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 31 Jul 2026 10:41:08 +0200 Subject: [PATCH 127/180] =?UTF-8?q?fix:=20rename=20setMarginEngine=20?= =?UTF-8?q?=E2=86=92=20setPortfolioMargin=20in=20keeper=20deploy=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keeper/tests/integration/deployStack.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index afb6095..5ac92c5 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -329,13 +329,13 @@ export async function deployStack(rpcUrl: string): Promise { [PERPS_LIQUIDATION_FEE], ); - // Futures -> PME + fees + validator URL. + // Futures -> PME + fees. await write( publicClient, owner.client, futures, futuresArt.abi, - "setMarginEngine", + "setPortfolioMargin", [pme], ); await write( From 339a59a16825212d12e30a538896b867d803654d Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 5 Aug 2026 17:17:12 +0200 Subject: [PATCH 128/180] feat(engine): stress resting orders and net PnL portfolio-wide Order margin was a flat add-on that could under-reserve a fill: an order that would push the account past its own stress could still rest. The engine now folds per-side order delta into the stress term and takes the worse of the two fill directions, so a resting order is always backed by the margin its fill would need. Splits the unrealized-PnL clamp by requirement. MM clamps the portfolio-wide signed sum so a hedged account is not liquidated for a loss it has already offset elsewhere; IM keeps clamping per market so unrealized gains can never fund a withdrawal or a new position. Co-authored-by: Cursor --- README.md | 13 +- contracts/abi/CollateralVault.json | 10 + contracts/abi/CollateralVault.ts | 10 + contracts/abi/ContractErrors.json | 30 ++ contracts/abi/ContractErrors.ts | 30 ++ contracts/abi/IPortfolioMarginEngine.json | 70 +++ contracts/abi/IPortfolioMarginEngine.ts | 70 +++ contracts/abi/PortfolioMarginEngine.json | 198 +++++-- contracts/abi/PortfolioMarginEngine.ts | 198 +++++-- contracts/contracts/CollateralVault.sol | 45 +- contracts/contracts/PortfolioMarginEngine.sol | 497 ++++++++++++++---- .../interfaces/AggregatorV3Interface.sol | 18 + contracts/contracts/interfaces/IFutures.sol | 35 -- .../interfaces/IHashPowerPerpsDEX.sol | 42 -- .../contracts/interfaces/ILinearMarket.sol | 73 +++ .../IOptionsEnginePortfolioView.sol | 7 + .../interfaces/IPortfolioMarginEngine.sol | 38 ++ contracts/contracts/libs/MathLib.sol | 39 ++ contracts/contracts/mocks/FuturesMock.sol | 66 ++- .../contracts/mocks/MalformedProductMock.sol | 13 + .../contracts/mocks/MarginEngineMock.sol | 25 + .../contracts/mocks/OptionsEngineMock.sol | 8 + contracts/contracts/mocks/PerpsDEXMock.sol | 84 +-- contracts/contracts/mocks/PriceOracleMock.sol | 66 +++ .../scripts/deploy-portfolio-margin-engine.ts | 17 +- .../scripts/update-portfolio-margin-engine.ts | 22 +- contracts/tests/collateralVault.test.ts | 64 ++- .../tests/crossMarginIntegration.test.ts | 70 ++- contracts/tests/fixtures.ts | 20 +- contracts/tests/portfolioMarginEngine.test.ts | 440 +++++++++++++++- docs/mango-attack.md | 159 ++++++ market-maker/pnpm-lock.yaml | 58 +- .../src/adapters/futures/instrument.ts | 64 ++- market-maker/src/adapters/futures/venue.ts | 54 +- market-maker/src/adapters/perps/instrument.ts | 61 ++- market-maker/src/adapters/perps/venue.ts | 23 +- market-maker/src/core/adapter.ts | 35 +- market-maker/src/core/collateralTracker.ts | 4 +- market-maker/src/core/healthcheck.ts | 4 +- market-maker/src/core/math.ts | 16 + market-maker/src/core/portfolioCollateral.ts | 10 +- market-maker/src/core/portfolioHealth.ts | 4 +- market-maker/src/core/rawOracle.ts | 16 +- .../tests-pending/market-maker.e2e.test.ts | 9 +- .../market-maker.process.test.ts | 18 +- .../tests/core/collateralTracker.test.ts | 2 +- market-maker/tests/core/math.test.ts | 23 + .../tests/core/portfolioCollateral.test.ts | 35 +- .../tests}/mm.test.ts | 0 .../tests}/solve.test.ts | 0 .../tests}/solveTarget.test.ts | 0 51 files changed, 2397 insertions(+), 516 deletions(-) create mode 100644 contracts/contracts/interfaces/AggregatorV3Interface.sol delete mode 100644 contracts/contracts/interfaces/IFutures.sol delete mode 100644 contracts/contracts/interfaces/IHashPowerPerpsDEX.sol create mode 100644 contracts/contracts/interfaces/ILinearMarket.sol create mode 100644 contracts/contracts/libs/MathLib.sol create mode 100644 contracts/contracts/mocks/MalformedProductMock.sol create mode 100644 contracts/contracts/mocks/PriceOracleMock.sol create mode 100644 docs/mango-attack.md rename {keeper/tests/predict => portfolio-margin/tests}/mm.test.ts (100%) rename {keeper/tests/predict => portfolio-margin/tests}/solve.test.ts (100%) rename {keeper/tests/predict => portfolio-margin/tests}/solveTarget.test.ts (100%) diff --git a/README.md b/README.md index 127398a..d6f0dc6 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This package implements a **portfolio-level** view of collateral need so one vau `PortfolioMarginEngine` is a **pluggable calculator** wired to: -- `IHashPowerPerpsDEX` — position qty, mark/oracle price, order margin, unrealized PnL, pending funding. +- `ILinearMarket` — `getRiskView(user)`: net position delta, unrealized PnL, pending funding, per-side resting-order delta, per-side instant fill loss. Implemented by both perps and futures; reports raw risk, never a margin figure. - `IOptionsEnginePortfolioView` — net options delta / gamma / vega (WAD-scaled) and **reserved** options margin (engine-specific floor). For a given `user` it computes **IM** (`computePortfolioIM`) and **MM** (`computePortfolioMM`): @@ -34,12 +34,17 @@ For a given `user` it computes **IM** (`computePortfolioIM`) and **MM** (`comput 2. **Four stress scenarios** — Spot moves by ±`imSpotShock` or ±`mmSpotShock` (fraction of price); vol moves by ±`imVolShock` or ±`mmVolShock` (WAD absolute IV change). For each corner it approximates PnL as `delta·Δs + ½·gamma·Δs² + vega·Δσ` (implemented in `_worstStressLoss` / `_scenarioLoss`) and takes the **worst loss** across scenarios (only losses count; gains are clipped at zero for that scenario). -3. **Add structured extras** (same for IM/MM path except shock sizes): - - resting **perp order margin** (`getOrderMargin`); +3. **Run the stress twice and keep the worse leg** — once at `netDelta + Σ buyOrderDelta`, once at `netDelta − Σ sellOrderDelta`. Resting orders are not a separate margin term; their delta is netted into the portfolio's and stressed as if they had filled. A subset of fills leaves net delta between the two legs, stress is convex in delta, so the maximum over that interval sits at an endpoint and the no-fill case is interior — which makes the reservation a provable upper bound over every fill subset. Nothing checks a maker's margin at fill time, so this is the only thing standing between a fill and an under-collateralized account. +4. **Add structured extras** (same for IM/MM path except shock sizes): + - per-side **instant fill loss** on resting orders (`buyOrderFillLoss + sellOrderFillLoss`, clamped per side so a favourably-priced order cannot fund an unfavourable one); - **options reserved margin** from the options engine (converted from WAD to token decimals); - - **unrealized perp loss** (only if PnL is negative); + - **unrealized loss**, clamped differently on the two paths — **IM** clamps per market, so a gain at one venue is ignored; **MM** clamps the portfolio-wide sum, so a gain at one venue offsets a loss at another. MM nets because it decides solvency and both legs settle into one vault, making the offset an accounting identity rather than a bet on correlation; without it a delta-flat cross-venue hedge is liquidated as soon as the mark moves, since the losing leg is charged in full while the winning leg is invisible. IM keeps the conservative form because it gates new risk and, through the vault's withdrawal check, the exit — netting there would let a manipulated mark on one venue release collateral against a real loss on another. Either way a net gain contributes zero rather than a credit, so unrealized profit can never discount the stress term; - **funding owed** (only if pending funding is positive — user owes the protocol). + Allowing profit to offset loss at all is the design choice the Mango Markets exploit is the cautionary tale for. [`docs/mango-attack.md`](./docs/mango-attack.md) works through that attack and why the IM/MM split makes it unprofitable here — in short, unrealized gain never funds a withdrawal or a new position, and even on the MM path a net gain contributes zero rather than a credit. + +`orderMarginOf(user)` reports what the resting orders alone add, by differencing the IM with and without them. It is portfolio-wide by construction — since order delta is netted before stressing, there is no per-venue slice to report and summing per-venue figures would both double-count the stress and miss the netting. It is also **not** constant in price, so off-chain models must re-evaluate it rather than snapshot it. + Defaults at `initialize` align rough intent with typical DEX buffers (e.g. 10% / 5% spot shocks for IM/MM, 10 / 5 vol points); governance can retune via `setShocks`. `CollateralVault` can call `computePortfolioIM` when a margin engine is set, so **withdrawals** respect **portfolio IM** (you cannot free collateral that the unified model still needs). diff --git a/contracts/abi/CollateralVault.json b/contracts/abi/CollateralVault.json index b2b9369..d07a0a8 100644 --- a/contracts/abi/CollateralVault.json +++ b/contracts/abi/CollateralVault.json @@ -127,6 +127,11 @@ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", @@ -196,6 +201,11 @@ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", diff --git a/contracts/abi/CollateralVault.ts b/contracts/abi/CollateralVault.ts index 8c77b0b..66c3336 100644 --- a/contracts/abi/CollateralVault.ts +++ b/contracts/abi/CollateralVault.ts @@ -127,6 +127,11 @@ export const CollateralVaultAbi = [ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", @@ -196,6 +201,11 @@ export const CollateralVaultAbi = [ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", diff --git a/contracts/abi/ContractErrors.json b/contracts/abi/ContractErrors.json index c198cb3..325dbdd 100644 --- a/contracts/abi/ContractErrors.json +++ b/contracts/abi/ContractErrors.json @@ -100,11 +100,21 @@ "name": "AccessControlBadConfirmation", "type": "error" }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, { "inputs": [], "name": "NotEnabled", "type": "error" }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, { "inputs": [], "name": "TransfersDisabled", @@ -126,6 +136,11 @@ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, { "inputs": [ { @@ -169,6 +184,16 @@ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "FailedCall", @@ -268,6 +293,11 @@ "name": "MintingFinalized", "type": "error" }, + { + "inputs": [], + "name": "OracleNotSet", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", diff --git a/contracts/abi/ContractErrors.ts b/contracts/abi/ContractErrors.ts index 27bf527..b0b170f 100644 --- a/contracts/abi/ContractErrors.ts +++ b/contracts/abi/ContractErrors.ts @@ -100,11 +100,21 @@ export const contractErrors = [ "name": "AccessControlBadConfirmation", "type": "error" }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, { "inputs": [], "name": "NotEnabled", "type": "error" }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, { "inputs": [], "name": "TransfersDisabled", @@ -126,6 +136,11 @@ export const contractErrors = [ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, { "inputs": [ { @@ -169,6 +184,16 @@ export const contractErrors = [ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "FailedCall", @@ -268,6 +293,11 @@ export const contractErrors = [ "name": "MintingFinalized", "type": "error" }, + { + "inputs": [], + "name": "OracleNotSet", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", diff --git a/contracts/abi/IPortfolioMarginEngine.json b/contracts/abi/IPortfolioMarginEngine.json index 41d8e7d..bb87587 100644 --- a/contracts/abi/IPortfolioMarginEngine.json +++ b/contracts/abi/IPortfolioMarginEngine.json @@ -37,6 +37,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "imSpotShock", @@ -50,6 +69,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + } + ], + "name": "linearOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "mmSpotShock", @@ -62,5 +100,37 @@ ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "orderMarginOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" } ] diff --git a/contracts/abi/IPortfolioMarginEngine.ts b/contracts/abi/IPortfolioMarginEngine.ts index 4e67af7..2b93977 100644 --- a/contracts/abi/IPortfolioMarginEngine.ts +++ b/contracts/abi/IPortfolioMarginEngine.ts @@ -37,6 +37,25 @@ export const IPortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "imSpotShock", @@ -50,6 +69,25 @@ export const IPortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + } + ], + "name": "linearOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "mmSpotShock", @@ -62,5 +100,37 @@ export const IPortfolioMarginEngineAbi = [ ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "orderMarginOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" } ] as const; diff --git a/contracts/abi/PortfolioMarginEngine.json b/contracts/abi/PortfolioMarginEngine.json index 3746747..54d4ff2 100644 --- a/contracts/abi/PortfolioMarginEngine.json +++ b/contracts/abi/PortfolioMarginEngine.json @@ -36,16 +36,41 @@ "name": "FailedCall", "type": "error" }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, { "inputs": [], "name": "NotInitializing", "type": "error" }, + { + "inputs": [], + "name": "OracleNotSet", + "type": "error" + }, { "inputs": [ { @@ -84,6 +109,11 @@ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", @@ -94,25 +124,38 @@ "inputs": [ { "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, "internalType": "address", - "name": "futures", + "name": "market", "type": "address" } ], - "name": "FuturesUpdated", + "name": "LinearMarketAdded", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" + "indexed": true, + "internalType": "address", + "name": "market", + "type": "address" } ], - "name": "Initialized", + "name": "LinearMarketRemoved", "type": "event" }, { @@ -132,32 +175,32 @@ "anonymous": false, "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "previousOwner", - "type": "address" - }, - { - "indexed": true, + "indexed": false, "internalType": "address", - "name": "newOwner", + "name": "oracle", "type": "address" } ], - "name": "OwnershipTransferred", + "name": "OracleUpdated", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, + "indexed": true, "internalType": "address", - "name": "perpsDex", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", "type": "address" } ], - "name": "PerpsDexUpdated", + "name": "OwnershipTransferred", "type": "event" }, { @@ -243,6 +286,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_market", + "type": "address" + } + ], + "name": "addLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -307,14 +363,33 @@ }, { "inputs": [], - "name": "futures", + "name": "getLinearMarkets", "outputs": [ { - "internalType": "contract IFutures", + "internalType": "address[]", "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", "type": "address" } ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], "stateMutability": "view", "type": "function" }, @@ -345,18 +420,19 @@ "type": "function" }, { - "inputs": [ - { - "internalType": "address", - "name": "_vault", - "type": "address" - } - ], + "inputs": [], "name": "initialize", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "initializeV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -376,6 +452,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + } + ], + "name": "linearOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "mmSpotShock", @@ -415,6 +510,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "orderMarginOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "owner", @@ -430,10 +544,10 @@ }, { "inputs": [], - "name": "perpsDex", + "name": "priceOracle", "outputs": [ { - "internalType": "contract IHashPowerPerpsDEX", + "internalType": "contract AggregatorV3Interface", "name": "", "type": "address" } @@ -454,22 +568,22 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { "internalType": "address", - "name": "_futuresEngine", + "name": "_market", "type": "address" } ], - "name": "setFutures", + "name": "removeLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -490,12 +604,12 @@ { "inputs": [ { - "internalType": "address", - "name": "_perpsEngine", + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", "type": "address" } ], - "name": "setPerps", + "name": "setOracle", "outputs": [], "stateMutability": "nonpayable", "type": "function" diff --git a/contracts/abi/PortfolioMarginEngine.ts b/contracts/abi/PortfolioMarginEngine.ts index 2ae082c..3bb317b 100644 --- a/contracts/abi/PortfolioMarginEngine.ts +++ b/contracts/abi/PortfolioMarginEngine.ts @@ -36,16 +36,41 @@ export const PortfolioMarginEngineAbi = [ "name": "FailedCall", "type": "error" }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, { "inputs": [], "name": "NotInitializing", "type": "error" }, + { + "inputs": [], + "name": "OracleNotSet", + "type": "error" + }, { "inputs": [ { @@ -84,6 +109,11 @@ export const PortfolioMarginEngineAbi = [ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", @@ -94,25 +124,38 @@ export const PortfolioMarginEngineAbi = [ "inputs": [ { "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, "internalType": "address", - "name": "futures", + "name": "market", "type": "address" } ], - "name": "FuturesUpdated", + "name": "LinearMarketAdded", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, - "internalType": "uint64", - "name": "version", - "type": "uint64" + "indexed": true, + "internalType": "address", + "name": "market", + "type": "address" } ], - "name": "Initialized", + "name": "LinearMarketRemoved", "type": "event" }, { @@ -132,32 +175,32 @@ export const PortfolioMarginEngineAbi = [ "anonymous": false, "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "previousOwner", - "type": "address" - }, - { - "indexed": true, + "indexed": false, "internalType": "address", - "name": "newOwner", + "name": "oracle", "type": "address" } ], - "name": "OwnershipTransferred", + "name": "OracleUpdated", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, + "indexed": true, "internalType": "address", - "name": "perpsDex", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", "type": "address" } ], - "name": "PerpsDexUpdated", + "name": "OwnershipTransferred", "type": "event" }, { @@ -243,6 +286,19 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_market", + "type": "address" + } + ], + "name": "addLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -307,14 +363,33 @@ export const PortfolioMarginEngineAbi = [ }, { "inputs": [], - "name": "futures", + "name": "getLinearMarkets", "outputs": [ { - "internalType": "contract IFutures", + "internalType": "address[]", "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", "type": "address" } ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], "stateMutability": "view", "type": "function" }, @@ -345,18 +420,19 @@ export const PortfolioMarginEngineAbi = [ "type": "function" }, { - "inputs": [ - { - "internalType": "address", - "name": "_vault", - "type": "address" - } - ], + "inputs": [], "name": "initialize", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "initializeV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -376,6 +452,25 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + } + ], + "name": "linearOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "mmSpotShock", @@ -415,6 +510,25 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "orderMarginOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "owner", @@ -430,10 +544,10 @@ export const PortfolioMarginEngineAbi = [ }, { "inputs": [], - "name": "perpsDex", + "name": "priceOracle", "outputs": [ { - "internalType": "contract IHashPowerPerpsDEX", + "internalType": "contract AggregatorV3Interface", "name": "", "type": "address" } @@ -454,22 +568,22 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { "internalType": "address", - "name": "_futuresEngine", + "name": "_market", "type": "address" } ], - "name": "setFutures", + "name": "removeLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -490,12 +604,12 @@ export const PortfolioMarginEngineAbi = [ { "inputs": [ { - "internalType": "address", - "name": "_perpsEngine", + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", "type": "address" } ], - "name": "setPerps", + "name": "setOracle", "outputs": [], "stateMutability": "nonpayable", "type": "function" diff --git a/contracts/contracts/CollateralVault.sol b/contracts/contracts/CollateralVault.sol index ee93541..1d0a0ce 100644 --- a/contracts/contracts/CollateralVault.sol +++ b/contracts/contracts/CollateralVault.sol @@ -30,6 +30,11 @@ contract CollateralVault is ICollateralVault, UUPSUpgradeable, OwnableUpgradeabl error NotAuthorized(); error ZeroAddress(); error FunctionDisabled(); + /// @notice The margin engine aggregates a different vault than this one. + error VaultMismatch(); + /// @dev A dependency did not answer a call the vault depends on: no code at the address, + /// or the call reverted. Which dependency is bad is implied by the setter that reverted. + error InvalidDependency(); // ── Events ────────────────────────────────────────────────────────────── @@ -100,17 +105,53 @@ contract CollateralVault is ICollateralVault, UUPSUpgradeable, OwnableUpgradeabl // ── Admin ─────────────────────────────────────────────────────────────── + function _authorizeUpgrade(address) internal override onlyOwner {} + function setAuthorizedCaller(address caller, bool authorized) external onlyOwner { if (caller == address(0)) revert ZeroAddress(); authorizedCallers[caller] = authorized; emit AuthorizedCallerSet(caller, authorized); } + /// @notice Set the margin engine that gates withdrawals. Pass `address(0)` to ungate them. + /// @dev Clearing is left open deliberately: it is the escape hatch if a broken engine would + /// otherwise trap every balance in the vault. A non-zero engine must aggregate *this* + /// vault — `computePortfolioIM` sizes the gate, so an engine reading another ledger + /// would report margin for positions this vault never collateralizes and wave the + /// withdrawal through. That failure is silent, unlike a wrong address, which reverts. function setMarginEngine(address _marginEngine) external onlyOwner { + if (_marginEngine != address(0)) { + _validateMarginEngine(_marginEngine); + } + marginEngine = _marginEngine; emit MarginEngineSet(_marginEngine); } + /// @dev `catch` only fires on a revert raised by the callee, so this check ahead of it is + /// load-bearing: a call to an address holding no code succeeds with empty return data + /// and fails later in this contract's decoder, out of the catch block's reach. + function _requireContract(address target) private view { + if (target.code.length == 0) revert InvalidDependency(); + } + + function _validateMarginEngine(address _marginEngine) private view { + _requireContract(_marginEngine); + + // Probe a plain storage read rather than `computePortfolioIM`: the margin path needs + // the engine's own oracle, and wiring the vault must not depend on that being set yet. + try IPortfolioMarginEngine(_marginEngine).imSpotShock() returns (uint256) { } + catch { + revert InvalidDependency(); + } + + try IPortfolioMarginEngine(_marginEngine).vault() returns (ICollateralVault pinned) { + if (address(pinned) != address(this)) revert VaultMismatch(); + } catch { + revert InvalidDependency(); + } + } + /// @notice Deposit collateral into the insurance fund from `source`, minting its receipt tokens. function depositInsuranceFund(uint256 amount) external { _depositFor(_msgSender(), INSURANCE_FUND_ADDR, amount); @@ -212,8 +253,4 @@ contract CollateralVault is ICollateralVault, UUPSUpgradeable, OwnableUpgradeabl function decimals() public view override returns (uint8) { return _decimals; } - - // ── Upgrade ───────────────────────────────────────────────────────────── - - function _authorizeUpgrade(address) internal override onlyOwner {} } diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 239be7c..6fac367 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -4,23 +4,60 @@ pragma solidity ^0.8.20; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {AggregatorV3Interface} from "./interfaces/AggregatorV3Interface.sol"; import {ICollateralVault} from "./interfaces/ICollateralVault.sol"; -import {IFutures} from "./interfaces/IFutures.sol"; -import {IHashPowerPerpsDEX} from "./interfaces/IHashPowerPerpsDEX.sol"; +import {ILinearMarket} from "./interfaces/ILinearMarket.sol"; import {IOptionsEnginePortfolioView} from "./interfaces/IOptionsEnginePortfolioView.sol"; import {IPortfolioMarginEngine} from "./interfaces/IPortfolioMarginEngine.sol"; import {Versionable} from "./interfaces/Versionable.sol"; +import {MathLib as M, WAD} from "./libs/MathLib.sol"; /// @title PortfolioMarginEngine — Cross-product portfolio margin -/// @notice Aggregates net Greeks across perps (linear delta), futures (linear -/// delta), and options (delta/gamma/vega), runs 4-scenario stress tests, -/// and computes the unified portfolio IM/MM requirement. -/// All three product legs (perps, futures, options) are optional and can -/// be registered or swapped at any time by the owner via the set* helpers. +/// @notice Aggregates net Greeks across all registered linear markets (delta-one +/// products) and the options engine (delta/gamma/vega), runs 4-scenario +/// stress tests, and computes the unified portfolio IM/MM requirement. +/// The engine is product-agnostic: linear markets are managed via +/// addLinearMarket/removeLinearMarket, options via setOptions. /// -/// portfolioIM = max(stressLoss) + perpsOrderMargin + futuresOrderMargin -/// + optionsReserved + max(0, -perpUnrealizedPnl) -/// + max(0, -futuresUnrealizedPnl) + max(0, perpPendingFunding) +/// portfolio{IM,MM} = max( stress(netDelta + Σ buyOrderDelta), +/// stress(netDelta − Σ sellOrderDelta) ) +/// + Σ buyOrderFillLoss + Σ sellOrderFillLoss + optionsReserved +/// + pnlTerm + Σ max(0, pendingFunding) +/// (sums over all registered linear markets) +/// +/// The two requirements differ only in their spot shock and in `pnlTerm`: +/// +/// IM: Σ max(0, -unrealizedPnl) — clamped per market, gains ignored +/// MM: max(0, -Σ unrealizedPnl) — clamped once, gains offset losses +/// +/// MM nets because it decides solvency, and a loss at one venue against a gain +/// at another is not a solvency event: both legs settle into one vault, in one +/// currency, so the offset is an accounting identity rather than a claim about +/// correlation. Clamping MM per market liquidates delta-flat cross-venue hedges +/// the moment the mark moves — the losing leg is charged in full while the +/// winning leg is invisible — which is the exact flow this engine exists to +/// support. Note the netted form still cannot go below zero: a net gain +/// contributes nothing, so unrealized profit can never fund a requirement +/// reduction beyond cancelling a loss the account actually carries. +/// +/// IM keeps the per-market clamp because it gates *new* risk and, via the +/// vault's withdrawal check, the exit. Netting there would let a manipulated +/// mark on one venue release collateral against a real loss on another. Holding +/// the conservative form on IM means unrealized profit supports survival but +/// never withdrawal or added leverage, and it preserves IM ≥ MM, which the +/// venues' `OverLiquidation` guard depends on. +/// +/// The two stress legs bound the requirement after *any* subset of the +/// account's resting orders fills: a subset leaves net delta somewhere in +/// [netDelta − sellOrderDelta, netDelta + buyOrderDelta], and stress is convex +/// in delta, so the maximum over that interval is attained at an endpoint. The +/// no-fill case is interior and therefore bounded too. That is the guarantee +/// the venues cannot provide themselves — there is no margin check on a maker +/// at fill time, so the reservation held against a resting order is the only +/// thing standing between a fill and an under-collateralized account. contract PortfolioMarginEngine is IPortfolioMarginEngine, Versionable, @@ -28,36 +65,65 @@ contract PortfolioMarginEngine is UUPSUpgradeable, OwnableUpgradeable { - uint256 private constant WAD = 1e18; - string public constant VERSION = "1.0.1"; + using EnumerableSet for EnumerableSet.AddressSet; + + uint256 private constant MAX_ORACLE_STALENESS = 1 hours; + string public constant VERSION = "2.0.0"; // ── Storage ───────────────────────────────────────────────────────────── ICollateralVault public vault; - IHashPowerPerpsDEX public perpsDex; + /// @dev Deprecated storage-layout placeholder (legacy `perpsDex` slot) — superseded + /// by `linearMarkets`. Read once by initializeV2 during migration; never written. + ILinearMarket private __deprecated_perpsDex; IOptionsEnginePortfolioView public optionsEngine; - IFutures public futures; + /// @dev Deprecated storage-layout placeholder (legacy `futures` slot). See above. + ILinearMarket private __deprecated_futures; - /// @dev Spot shock for IM (WAD fraction, e.g. 0.15e18 = 15%). + /// @dev Spot shock for IM (WAD fraction, e.g. WAD * 15 / 100 = 15%). uint256 public imSpotShock; /// @dev Spot shock for MM. uint256 public mmSpotShock; - /// @dev Vol shock for IM (WAD absolute IV change, e.g. 0.10e18 = 10 vol pts). + /// @dev Vol shock for IM (WAD absolute IV change, e.g. WAD / 10 = 10 vol pts). uint256 public imVolShock; /// @dev Vol shock for MM. uint256 public mmVolShock; + /// @dev Cached decimals of the vault's collateral token — the shared quote unit for + /// all product prices/PnL. Cached so product contracts don't need to re-expose it. + uint8 private collateralDecimals; + + /// @dev Registered linear markets (delta-one products). Margin math iterates + /// this set; the engine has no product-specific knowledge. Duplicates are + /// rejected at registration — they would silently double-count margin. + EnumerableSet.AddressSet private linearMarkets; + + /// @dev Hashprice index oracle — the PME's own spot source for stress math, + /// independent of any registered product. + AggregatorV3Interface public priceOracle; + uint8 private oracleDecimals; + // ── Events ────────────────────────────────────────────────────────────── event ShocksUpdated(uint256 imSpot, uint256 mmSpot, uint256 imVol, uint256 mmVol); - event PerpsDexUpdated(address perpsDex); + event LinearMarketAdded(address indexed market); + event LinearMarketRemoved(address indexed market); event OptionsEngineUpdated(address optionsEngine); - event FuturesUpdated(address futures); event VaultUpdated(address vault); + event OracleUpdated(address oracle); // ── Errors ────────────────────────────────────────────────────────────── error ZeroAddress(); + error LinearMarketAlreadyRegistered(); + error LinearMarketNotRegistered(); + error OracleNotSet(); + error InvalidOracle(); + 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 + /// implied by the setter that reverted. + error InvalidDependency(); // ── Initializer ───────────────────────────────────────────────────────── @@ -66,22 +132,35 @@ contract PortfolioMarginEngine is _disableInitializers(); } - function initialize(address _vault) external initializer { + function initialize() external initializer { __Ownable_init(_msgSender()); __UUPSUpgradeable_init(); - if (_vault == address(0)) revert ZeroAddress(); + imSpotShock = WAD / 10; // 10% — matches DEX marginPercent + mmSpotShock = WAD / 20; // 5% — matches DEX maintenanceMarginPercent + imVolShock = WAD / 10; // 10 vol points + mmVolShock = WAD / 20; // 5 vol points + } - vault = ICollateralVault(_vault); + /// @notice Backfills `collateralDecimals` and migrates the legacy perps/futures + /// registrations into `linearMarkets` on proxies initialized before those + /// were introduced. Must run before the upgrade serves margin calls — + /// decimals stay 0 and no linear market is registered otherwise. + function initializeV2() external reinitializer(2) { + collateralDecimals = _readDecimals(address(vault.collateralToken())); - imSpotShock = 0.1e18; // 10% — matches DEX marginPercent - mmSpotShock = 0.05e18; // 5% — matches DEX maintenanceMarginPercent - imVolShock = 0.1e18; // 10 vol points - mmVolShock = 0.05e18; // 5 vol points + if (address(__deprecated_perpsDex) != address(0)) { + linearMarkets.add(address(__deprecated_perpsDex)); + } + if (address(__deprecated_futures) != address(0)) { + linearMarkets.add(address(__deprecated_futures)); + } } // ── Admin ─────────────────────────────────────────────────────────────── + function _authorizeUpgrade(address) internal override onlyOwner {} + function setShocks(uint256 _imSpotShock, uint256 _mmSpotShock, uint256 _imVolShock, uint256 _mmVolShock) external onlyOwner @@ -93,27 +172,71 @@ contract PortfolioMarginEngine is emit ShocksUpdated(_imSpotShock, _mmSpotShock, _imVolShock, _mmVolShock); } + /// @dev Smoke-tests the vault's read surface before adopting it. A wrong address here + /// breaks every margin computation, and so every withdrawal gate, at once. function setVault(address _vault) external onlyOwner { - vault = ICollateralVault(_vault); - emit VaultUpdated(_vault); + _validateNotZeroAddress(_vault); + _requireContract(_vault); + address token = _validateVaultContract(_vault); + _validateMarketsUseSameVault(_vault); + + vault = ICollateralVault(_vault); + collateralDecimals = _readDecimals(token); + emit VaultUpdated(_vault); + } + + /// @notice Register a linear market (any delta-one product). Reverts if already + /// registered — a duplicate would silently double-count margin. + /// @dev The market must settle into this engine's vault: margin read from one ledger + /// and balances from another is not a portfolio. + function addLinearMarket(address _market) external onlyOwner { + _validateNotZeroAddress(_market); + _requireContract(_market); + _requireVaultPin(_market, vault); + + if (!linearMarkets.add(_market)) { + revert LinearMarketAlreadyRegistered(); + } + + emit LinearMarketAdded(_market); + } + + /// @notice Deregister a linear market. Reverts if not registered. + function removeLinearMarket(address _market) external onlyOwner { + if (!linearMarkets.remove(_market)) revert LinearMarketNotRegistered(); + + emit LinearMarketRemoved(_market); } - /// @notice Register (or deregister) the perps DEX. Pass address(0) to disable. - function setPerps(address _perpsEngine) external onlyOwner { - perpsDex = IHashPowerPerpsDEX(_perpsEngine); - emit PerpsDexUpdated(_perpsEngine); + /// @notice All registered linear markets. + function getLinearMarkets() external view returns (address[] memory) { + return linearMarkets.values(); } /// @notice Register (or deregister) the options engine. Pass address(0) to disable. function setOptions(address _optionsEngine) external onlyOwner { + if (_optionsEngine != address(0)) { + _requireContract(_optionsEngine); + _validateOptionsContract(_optionsEngine); + _requireVaultPin(_optionsEngine, vault); + } + optionsEngine = IOptionsEnginePortfolioView(_optionsEngine); emit OptionsEngineUpdated(_optionsEngine); } - /// @notice Register (or deregister) the futures contract. Pass address(0) to disable. - function setFutures(address _futuresEngine) external onlyOwner { - futures = IFutures(_futuresEngine); - emit FuturesUpdated(_futuresEngine); + /// @notice Set the index oracle used for stress-math spot. Must be configured — + /// margin computations revert with `OracleNotSet` while it is unset. + /// @dev Requires the feed to already serve a positive, initialized round: a feed that + /// never answers reads as spot 0, which zeroes the delta/gamma stress loss. + function setOracle(AggregatorV3Interface _oracle) external onlyOwner { + _validateNotZeroAddress(address(_oracle)); + _requireContract(address(_oracle)); + _validateOracleContract(_oracle); + + priceOracle = _oracle; + oracleDecimals = _readDecimals(address(_oracle)); + emit OracleUpdated(address(_oracle)); } // ── Core views ───────────────────────────────────────────────────────── @@ -129,6 +252,21 @@ contract PortfolioMarginEngine is return _computeMargin(user, false); } + /// @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 + /// every venue. Exposing it applied rather than raw keeps the WAD scale inside + /// the engine — markets quote notionals in collateral decimals and get margin + /// back in the same unit. + /// + /// Deliberately not a general order-margin entry point: option orders are sized + /// from greeks, not notional. Serving both would mean stressing (delta, gamma, + /// vega) here instead, which is worth doing when options is wired in — it would + /// also retire the duplicate shock config in `OptionMarginEngine`. + function linearOrderMargin(uint256 notional) external view returns (uint256) { + return notional * imSpotShock / WAD; + } + /// @notice Check if user is healthy (balance >= MM). function isHealthy(address user) external view returns (bool) { return vault.balanceOf(user) >= _computeMargin(user, false); @@ -139,73 +277,132 @@ contract PortfolioMarginEngine is return vault.balanceOf(user) >= _computeMargin(user, true) + additionalIM; } - // ── Internal ──────────────────────────────────────────────────────────── - - function _computeMargin(address user, bool isIM) private view returns (uint256) { - // 1. Aggregate net Greeks (WAD-scaled) - (int256 netDelta, uint256 netGamma, uint256 netVega) = _aggregateGreeks(user); + /// @notice The incremental IM the user's resting orders actually cost (token decimals): + /// the portfolio IM as charged, less the IM the same portfolio would carry with + /// no orders resting. + /// @dev This is what a UI should display as "order margin". Unlike the per-venue scalar + /// it replaces, it is exact and cross-product: an order that genuinely offsets + /// exposure held at another venue costs nothing here, and an order that looks + /// risk-reducing to its own venue but takes the portfolio further from flat is + /// charged in full. + /// + /// Non-additive across orders by construction — the stress term is convex, so + /// the cost of two orders is not the sum of their individual costs. Callers + /// 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); + + agg.buyOrderDelta = 0; + agg.sellOrderDelta = 0; + agg.fillLoss = 0; + uint256 withoutOrders = _marginFromAggregate(user, agg, true); + + return withOrders > withoutOrders ? withOrders - withoutOrders : 0; + } - // 2. Four-scenario stress loss (WAD-scaled) - uint256 worstLoss = _worstStressLoss(netDelta, netGamma, netVega, isIM); + /// @notice Whether any registered linear market reports resting order delta for `user`. + /// @dev Backs the venues' orders-first gate on position liquidation. Each venue can only + /// see its own book, but the requirement is portfolio-level: a position on one venue + /// offsets resting orders on another, so closing it strands the opposing leg and + /// raises the very requirement the liquidation was meant to relieve. Gating on this + /// puts the check at the same scope as the margin it protects. + /// + /// 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`. + 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; + } + return false; + } - // 3. Perps add-ons (optional) - uint256 perpOrderMargin = 0; - uint256 unrealizedLoss = 0; - uint256 fundingOwed = 0; - if (address(perpsDex) != address(0)) { - perpOrderMargin = perpsDex.getOrderMargin(user); + // ── Internal ──────────────────────────────────────────────────────────── - int256 perpPnl = perpsDex.getUnrealizedPnl(user); - unrealizedLoss = perpPnl < 0 ? uint256(-perpPnl) : 0; + /// @dev Summed `ILinearMarket.RiskView` across every registered market. Deltas are + /// WAD-lifted (the engine's internal scale); the monetary add-ons stay in token + /// decimals, as the markets report them. + struct LinearAggregate { + int256 netDelta; + uint256 buyOrderDelta; + uint256 sellOrderDelta; + uint256 fillLoss; + uint256 unrealizedLossPerMarket; + int256 netUnrealizedPnl; + uint256 fundingOwed; + } - int256 pendingFunding = perpsDex.getPendingFunding(user); - fundingOwed = pendingFunding > 0 ? uint256(pendingFunding) : 0; - } + function _computeMargin(address user, bool isIM) private view returns (uint256) { + return _marginFromAggregate(user, _linearAggregate(user), isIM); + } - // 4. Options reserved margin (WAD → token decimals, optional) + /// @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) + private + view + returns (uint256) + { + // 1. Options Greeks — WAD-scaled signed delta, unsigned gamma/vega (optional) + int256 netDelta = agg.netDelta; + uint256 netGamma = 0; + uint256 netVega = 0; uint256 optReservedTokens = 0; if (address(optionsEngine) != address(0)) { - optReservedTokens = _fromWad(optionsEngine.getOptionsReservedMargin(user)); + (int256 optDelta, uint256 optGamma, uint256 optVega) = optionsEngine.getNetGreeks(user); + netDelta += optDelta; + netGamma = optGamma; + netVega = optVega; + optReservedTokens = M.fromWad(optionsEngine.getOptionsReservedMargin(user), collateralDecimals); } - // 5. Futures add-ons (optional) - uint256 futuresOrderMargin = 0; - uint256 futuresUnrealizedLoss = 0; - if (address(futures) != address(0)) { - futuresOrderMargin = futures.getOrderMargin(user); - int256 futuresPnl = futures.getUnrealizedPnl(user); - futuresUnrealizedLoss = futuresPnl < 0 ? uint256(-futuresPnl) : 0; - } + // 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); + if (sellLoss > worstLoss) worstLoss = sellLoss; // Convert stress loss from WAD to token decimals - uint256 stressTokens = _fromWad(worstLoss); + uint256 stressTokens = M.fromWad(worstLoss, collateralDecimals); - return stressTokens + perpOrderMargin + futuresOrderMargin + optReservedTokens + unrealizedLoss - + futuresUnrealizedLoss + fundingOwed; - } - - /// @dev Aggregate net Greeks across perps (linear delta), futures (linear delta), - /// and options (delta/gamma/vega). Each leg is queried only when registered. - function _aggregateGreeks(address user) private view returns (int256 netDelta, uint256 netGamma, uint256 netVega) { - // Perps delta: qty * WAD / 10^quantityDecimals (optional) - if (address(perpsDex) != address(0)) { - IHashPowerPerpsDEX.Position memory pos = perpsDex.getUserPosition(user); - int256 qtyScale = int256(10 ** uint256(perpsDex.QUANTITY_DECIMALS())); - netDelta += pos.netQuantity * int256(WAD) / qtyScale; - } + // 3. Unrealized PnL. IM clamps per market and so ignores gains entirely; MM clamps + // the portfolio-wide sum, letting a gain at one venue offset a loss at another. + // See the contract natspec for why the two differ. + uint256 pnlTokens = isIM + ? agg.unrealizedLossPerMarket + : (agg.netUnrealizedPnl < 0 ? uint256(-agg.netUnrealizedPnl) : 0); - // Futures delta: sum(±qty) * WAD per active position — one WAD per contract - // (1 PH/s/day), sign per side. No duration multiplier. (optional) - if (address(futures) != address(0)) { - netDelta += futures.getNetPositionDelta(user); - } + return stressTokens + agg.fillLoss + optReservedTokens + pnlTokens + agg.fundingOwed; + } - // Options Greeks — WAD-scaled signed delta, unsigned gamma/vega (optional) - if (address(optionsEngine) != address(0)) { - (int256 optDelta, uint256 optGamma, uint256 optVega) = optionsEngine.getNetGreeks(user); - netDelta += optDelta; - netGamma = optGamma; - netVega = optVega; + /// @dev One batched getRiskView call per registered linear market: sums the WAD-lifted + /// net and per-side order deltas alongside the fill-loss / negative-PnL / + /// funding-owed add-ons. + /// + /// Both sides' fill losses are summed into one term and charged in both stress + /// legs. That over-reserves slightly, and deliberately so: it removes any + /// dependence on an argument about which side can carry a loss at a given spot. + /// Futures orders at different expiries are not mutually crossed, so both sides + /// genuinely can. + function _linearAggregate(address user) private view returns (LinearAggregate memory agg) { + uint256 len = linearMarkets.length(); + for (uint256 i = 0; i < len; i++) { + ILinearMarket.RiskView memory account = ILinearMarket(linearMarkets.at(i)).getRiskView(user); + + agg.netDelta += M.toWad(account.netPositionDelta, collateralDecimals); + agg.buyOrderDelta += M.toWad(account.buyOrderDelta, collateralDecimals); + agg.sellOrderDelta += M.toWad(account.sellOrderDelta, collateralDecimals); + agg.fillLoss += account.buyOrderFillLoss + account.sellOrderFillLoss; + agg.netUnrealizedPnl += account.unrealizedPnl; + if (account.unrealizedPnl < 0) agg.unrealizedLossPerMarket += uint256(-account.unrealizedPnl); + if (account.pendingFunding > 0) agg.fundingOwed += uint256(account.pendingFunding); } } @@ -260,32 +457,118 @@ contract PortfolioMarginEngine is return pnl < 0 ? uint256(-pnl) : 0; } - /// @dev Read spot price and scale to WAD. Tries perpsDex first, then futures. - /// Returns 0 (no stress scenarios) when neither price source is registered. - function _getSpotPriceWad() private view returns (uint256) { - if (address(perpsDex) != address(0)) { - return perpsDex.getMarketPrice() * _wadScale(perpsDex.decimals()); + + + /// @dev `ILinearMarket.vault` and `IOptionsEnginePortfolioView.vault` share one + /// selector, so this serves both product families. + function _requireVaultPin(address product, ICollateralVault expected) private view { + if (_pinnedVault(product) != address(expected)) revert VaultMismatch(); + } + + // ── Dependency probes ─────────────────────────────────────────────────── + // + // `catch` only fires on a revert raised by the callee, so the code check ahead of it + // is load-bearing: a call to an address holding no code succeeds with empty return + // data and fails later in this contract's decoder, out of the catch block's reach. + // The one gap left is a contract carrying the right selector but answering with a + // wrong-shaped payload — that still escapes as a bare revert. + + function _requireContract(address target) private view { + if (target.code.length == 0) revert InvalidDependency(); + } + + function _pinnedVault(address product) private view returns (address) { + try ILinearMarket(product).vault() returns (ICollateralVault pinned) { + return address(pinned); + } catch { + revert InvalidDependency(); } - if (address(futures) != address(0)) { - return futures.getMarketPrice() * _wadScale(futures.decimals()); + } + + /// @dev `IERC20Metadata.decimals` and `AggregatorV3Interface.decimals` share one + /// selector, so this serves the collateral token and the price feed alike. + function _readDecimals(address target) private view returns (uint8) { + try IERC20Metadata(target).decimals() returns (uint8 dec) { + return dec; + } catch { + revert InvalidDependency(); } - return 0; } - function _fromWad(uint256 wadAmount) private view returns (uint256) { - uint8 dec; - if (address(perpsDex) != address(0)) dec = perpsDex.decimals(); - else if (address(futures) != address(0)) dec = futures.decimals(); - return wadAmount / _wadScale(dec); + /// @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). + 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; + return M.toWad(uint256(answer), oracleDecimals); } - /// @dev 10^(18 − dec): multiply a `dec`-decimal value by this to get WAD, - /// divide a WAD value by this to get `dec`-decimal units. - function _wadScale(uint8 dec) private pure returns (uint256) { - return 10 ** (18 - dec); + function _validateNotZeroAddress(address addr) private view { + if (addr == address(0)) revert ZeroAddress(); } - // ── Upgrade ───────────────────────────────────────────────────────────── + function _validateMarketsUseSameVault(address _vault) private view { + // Products pin their vault at construction, so swapping the engine's vault out + // from under live registrations can only mean the two have diverged. Deregister + // the stale products first. + uint256 len = linearMarkets.length(); + for (uint256 i = 0; i < len; i++) { + _requireVaultPin(linearMarkets.at(i), ICollateralVault(_vault)); + } + if (address(optionsEngine) != address(0)) { + _requireVaultPin(address(optionsEngine), ICollateralVault(_vault)); + } + } + + function _validateVaultContract(address _vault) private view returns (address token){ + // Smoke-test both reads the engine depends on before comparing product pins, + // so a bad vault reports its own problem rather than a mismatch. + ICollateralVault newVault = ICollateralVault(_vault); + try newVault.balanceOf(address(this)) returns (uint256) { } + catch { + revert InvalidDependency(); + } + + try newVault.collateralToken() returns (IERC20 _token) { + return address(_token); + } catch { + revert InvalidDependency(); + } + } + + function _validateLinearMarketContract(address _market) private view{ + try ILinearMarket(_market).getRiskView(address(this)) returns (ILinearMarket.RiskView memory) { } + catch { + revert InvalidDependency(); + } + } + + function _validateOptionsContract(address _optionsEngine)private view{ + try IOptionsEnginePortfolioView(_optionsEngine).getNetGreeks(address(this)) returns ( + int256, uint256, uint256 + ) { } catch { + revert InvalidDependency(); + } + + try IOptionsEnginePortfolioView(_optionsEngine).getOptionsReservedMargin(address(this)) returns (uint256) { } + catch { + revert InvalidDependency(); + } + } + + function _validateOracleContract(AggregatorV3Interface _oracle) private view{ + int256 answer; + uint256 updatedAt; + try _oracle.latestRoundData() returns (uint80, int256 _answer, uint256, uint256 _updatedAt, uint80) { + answer = _answer; + updatedAt = _updatedAt; + } catch { + revert InvalidDependency(); + } + if (answer <= 0 || updatedAt == 0) revert InvalidOracle(); + } - function _authorizeUpgrade(address) internal override onlyOwner {} } diff --git a/contracts/contracts/interfaces/AggregatorV3Interface.sol b/contracts/contracts/interfaces/AggregatorV3Interface.sol new file mode 100644 index 0000000..d53492b --- /dev/null +++ b/contracts/contracts/interfaces/AggregatorV3Interface.sol @@ -0,0 +1,18 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title AggregatorV3Interface +/// @notice Chainlink-style price oracle interface +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + function description() external view returns (string memory); + function version() external view returns (uint256); + function getRoundData(uint80 _roundId) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} diff --git a/contracts/contracts/interfaces/IFutures.sol b/contracts/contracts/interfaces/IFutures.sol deleted file mode 100644 index 3b491ab..0000000 --- a/contracts/contracts/interfaces/IFutures.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title IFutures — Portfolio-margin view interface for the Futures contract -/// @notice Exposes the three view functions needed by `PortfolioMarginEngine` to -/// incorporate hashrate futures into cross-product margin calculation. -/// -/// Delta convention (WAD = 1e18): -/// Whole-contract futures 3.0: each contract of net quantity contributes -/// ±1 WAD of delta (token-decimals of PnL per token-decimal move in -/// the hashrate price), matching the scaling used for perp delta. -interface IFutures { - /// @notice Net linear delta of all *active positions* (WAD-scaled, signed). - /// Positive = net long exposure; negative = net short. - /// Only counts matched positions, not resting orders (those are - /// captured via `getOrderMargin`). - function getNetPositionDelta(address participant) external view returns (int256); - - /// @notice Minimum margin locked by resting orders (token decimals). - /// Mirrors `getOrderMargin` in IHashPowerPerpsDEX: it is the - /// maintenance-margin-less-unrealized-PnL component for unmatched - /// orders, clamped to zero (orders can't produce a net credit). - function getOrderMargin(address participant) external view returns (uint256); - - /// @notice Aggregate unrealized PnL across active positions (token decimals). - /// Positive = mark-to-market gain; negative = mark-to-market loss. - function getUnrealizedPnl(address participant) external view returns (int256); - - /// @notice Current oracle-derived hashrate spot price (token decimals). - /// Used as a fallback price source when no perps DEX is registered. - function getMarketPrice() external view returns (uint256); - - /// @notice Decimals of the collateral token (e.g. 6 for USDC, 18 for DAI). - function decimals() external view returns (uint8); -} diff --git a/contracts/contracts/interfaces/IHashPowerPerpsDEX.sol b/contracts/contracts/interfaces/IHashPowerPerpsDEX.sol deleted file mode 100644 index 209a70d..0000000 --- a/contracts/contracts/interfaces/IHashPowerPerpsDEX.sol +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title IHashPowerPerpsDEX — Read interface for perps + options integration -/// @notice Exposes view functions from HashPowerPerpsDEX needed by the -/// portfolio margin engine for cross-product margin calculation. -interface IHashPowerPerpsDEX { - struct Position { - int256 netQuantity; - uint256 aggregatedEntryPrice; - } - - /// @notice User's net perp position. - function getUserPosition(address user) external view returns (Position memory); - - /// @notice Unrealized PnL at current oracle price (includes pending funding). - function getUnrealizedPnl(address user) external view returns (int256); - - /// @notice Initial margin required for the user's perp position + resting orders. - function getInitialMargin(address user) external view returns (uint256); - - /// @notice Maintenance margin required for the user's perp position + resting orders. - function getMaintenanceMargin(address user) external view returns (uint256); - - /// @notice Resting-order margin component only (excludes position margin). - function getOrderMargin(address user) external view returns (uint256); - - /// @notice Pending (unsettled) funding. Positive = user owes. - function getPendingFunding(address user) external view returns (int256); - - /// @notice Whether the user's perp account is liquidatable. - function isLiquidatable(address user) external view returns (bool); - - /// @notice Perp quantity decimals (6). - function QUANTITY_DECIMALS() external view returns (uint8); - - /// @notice Current oracle-derived spot price (in token decimals). - function getMarketPrice() external view returns (uint256); - - /// @notice Token decimals of the collateral token used by the DEX. - function decimals() external view returns (uint8); -} diff --git a/contracts/contracts/interfaces/ILinearMarket.sol b/contracts/contracts/interfaces/ILinearMarket.sol new file mode 100644 index 0000000..da9eb6f --- /dev/null +++ b/contracts/contracts/interfaces/ILinearMarket.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ICollateralVault} from "./ICollateralVault.sol"; + +/// @title ILinearMarket — Portfolio-margin view interface for delta-one products +/// @notice Uniform read interface used by `PortfolioMarginEngine` for linear-payoff +/// markets (perpetuals, futures) — as opposed to options, whose non-linear +/// payoff is exposed via `IOptionsEnginePortfolioView`. +/// +/// All values are denominated in the collateral token's decimals — products +/// know nothing about WAD (the PME's internal fixed-point scale). +/// +/// Delta convention: scaled by 10^collateralDecimals, i.e. +/// PnL (token decimals) = delta × priceMove (token decimals) / 10^collateralDecimals. +/// Each product does its own quantity→delta scaling internally (perps divide +/// out quantity decimals; futures count one delta unit per contract) so the +/// PME never needs product-specific constants. +interface ILinearMarket { + /// @notice The collateral vault this market settles into. + /// @dev Read by `PortfolioMarginEngine.addLinearMarket` to pin a market to the + /// engine's own vault. A market settling into a different ledger would have + /// its margin aggregated against balances it never touches. + function vault() external view returns (ICollateralVault); + + /// @notice All per-user margin inputs, batched into a single call to save + /// external-call gas. + /// + /// Markets report raw post-fill exposure rather than a margin figure: the + /// engine nets `buyOrderDelta` / `sellOrderDelta` into portfolio net delta + /// and stresses each leg, which bounds every fill subset by convexity and + /// nets across venues. A per-venue scalar can do neither. + /// @param netPositionDelta Net linear delta of all *active positions* (signed, + /// scaled by 10^collateralDecimals). Positive = net long; negative = net + /// short. Only matched positions, not resting orders. + /// @param unrealizedPnl Aggregate mark-to-market PnL across active positions (token + /// decimals, signed). Must exclude pending funding — the engine adds a loss + /// here and `pendingFunding` as independent terms, so a market that nets + /// funding into this field has the debt charged twice. + /// @param pendingFunding Pending unsettled funding (token decimals, signed; + /// positive = user owes). Products without funding (futures) return 0. + /// @param buyOrderDelta Delta the account would acquire if every resting bid filled: + /// Σ|q| over bids, unsigned, scaled by 10^collateralDecimals exactly as + /// `netPositionDelta` is. The engine adds it to net delta, so the same + /// quantity→delta scaling must apply. + /// @param sellOrderDelta Delta the account would shed if every resting ask filled: + /// Σ|q| over asks, unsigned, same scale as `buyOrderDelta`. The engine + /// subtracts it from net delta. + /// @param buyOrderFillLoss Instant mark-to-market loss if every resting bid filled: + /// max(0, Σ q·(limit − mark)) over bids (token decimals). Clamped at the + /// scenario level, not per order — in the all-bids-fill world those orders + /// fill together and their gains and losses genuinely net. + /// @param sellOrderFillLoss Instant mark-to-market loss if every resting ask filled: + /// max(0, Σ q·(mark − limit)) over asks (token decimals). Same scenario-level + /// clamp as `buyOrderFillLoss`. + struct RiskView { + int256 netPositionDelta; + int256 unrealizedPnl; + int256 pendingFunding; + uint256 buyOrderDelta; + uint256 sellOrderDelta; + uint256 buyOrderFillLoss; + uint256 sellOrderFillLoss; + } + + /// @notice Batched read of the user's margin inputs (see RiskView). + /// @dev Deliberately a new selector rather than an extension of the former + /// `getAccountView`. Engine and markets are independently upgraded UUPS + /// proxies; the old four-word decoder reading this seven-word tuple would + /// 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); +} diff --git a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol index 8894fd2..6af0dd9 100644 --- a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol +++ b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol @@ -1,10 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {ICollateralVault} from "./ICollateralVault.sol"; + /// @title IOptionsEnginePortfolioView — Options surface used by PortfolioMarginEngine /// @notice Implemented by `OptionMarginEngine` in the perps package; keeps this package /// independent of concrete options logic. interface IOptionsEnginePortfolioView { + /// @notice The collateral vault this engine settles into. + /// @dev Read by `PortfolioMarginEngine.setOptions` to pin the engine to the engine's + /// 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 getOptionsReservedMargin(address user) external view returns (uint256); diff --git a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol index d5e6376..4cc73cf 100644 --- a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol +++ b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol @@ -1,16 +1,54 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {ICollateralVault} from "./ICollateralVault.sol"; + /// @title IPortfolioMarginEngine — Interface for cross-product margin checks /// @notice Used by product engines (perps DEX, options engine) to delegate /// margin validation to the portfolio-level margin engine. interface IPortfolioMarginEngine { + /// @notice The collateral vault this engine aggregates balances from. + /// @dev Read by the products' `setPortfolioMargin` to pin the engine to the product's + /// own vault. An engine sizing margin against a different ledger would gate + /// trades on balances the product never debits. Mirrors `ILinearMarket.vault`. + function vault() external view returns (ICollateralVault); + /// @notice Portfolio Initial Margin in token decimals. function computePortfolioIM(address user) external view returns (uint256); /// @notice Portfolio Maintenance Margin in token decimals. function computePortfolioMM(address user) external view returns (uint256); + /// @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 + /// the engine's WAD fixed-point scale, so the shock and the scale it is expressed + /// in can never drift apart across contracts. + /// + /// Linear products only — a notional cannot express the delta/gamma/vega an + /// option's margin depends on. Options size resting orders through their own + /// engine and report the total via `IOptionsEnginePortfolioView`. + function linearOrderMargin(uint256 notional) external view returns (uint256); + + /// @notice Incremental portfolio IM attributable to the user's resting orders + /// (token decimals): IM as charged, less IM with no orders resting. + /// @dev The display figure for "margin locked by my orders". Exact and cross-product, + /// so it can read zero for an order that offsets exposure at another venue. + /// Not additive across orders — the stress term it differences is convex. + function orderMarginOf(address user) external view returns (uint256); + + /// @notice Whether any registered linear market reports resting order delta for `user`. + /// @dev The orders-first gate on position liquidation. A venue can only see its own + /// book, but margin is portfolio-level: a position on one venue offsets resting + /// orders on another, so closing it leaves the opposing leg unopposed and *raises* + /// the requirement the liquidation was meant to relieve. Gating each venue on this + /// instead of its own order index makes the check match the scope of the margin. + /// + /// Keyed on delta rather than order count so an order carrying no risk — an expired + /// futures order still sitting in its participant index — cannot deadlock + /// liquidation. Cancelling orders stays ungated; it is the remedy this gate points at. + function hasRestingOrderDelta(address user) external view returns (bool); + /// @notice IM spot shock as WAD fraction (e.g. 0.10e18 = 10%). function imSpotShock() external view returns (uint256); diff --git a/contracts/contracts/libs/MathLib.sol b/contracts/contracts/libs/MathLib.sol new file mode 100644 index 0000000..7986c8f --- /dev/null +++ b/contracts/contracts/libs/MathLib.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @dev 18-decimal fixed-point scale used for cross-product margin math +/// ("wad" = wei-scale arithmetic unit). +uint8 constant WAD_DECIMALS = 18; +uint256 constant WAD = 10 ** WAD_DECIMALS; + +/// @title MathLib — Pure math helpers +library MathLib { + /// @notice Scale a value from one decimal precision to another. + function scaleDecimals(uint256 value, uint8 fromDecimals, uint8 toDecimals) internal pure returns (uint256) { + if (fromDecimals == toDecimals) return value; + if (fromDecimals < toDecimals) return value * (10 ** (toDecimals - fromDecimals)); + return value / (10 ** (fromDecimals - toDecimals)); + } + + /// @notice Signed overload. + function scaleDecimals(int256 value, uint8 fromDecimals, uint8 toDecimals) internal pure returns (int256) { + if (fromDecimals == toDecimals) return value; + if (fromDecimals < toDecimals) return value * int256(10 ** (toDecimals - fromDecimals)); + return value / int256(10 ** (fromDecimals - toDecimals)); + } + + /// @notice Scale a `fromDecimals`-decimal value up to WAD. + function toWad(uint256 value, uint8 fromDecimals) internal pure returns (uint256) { + return scaleDecimals(value, fromDecimals, WAD_DECIMALS); + } + + /// @notice Signed overload. + function toWad(int256 value, uint8 fromDecimals) internal pure returns (int256) { + return scaleDecimals(value, fromDecimals, WAD_DECIMALS); + } + + /// @notice Scale a WAD value down to `toDecimals`. + function fromWad(uint256 wadAmount, uint8 toDecimals) internal pure returns (uint256) { + return scaleDecimals(wadAmount, WAD_DECIMALS, toDecimals); + } +} diff --git a/contracts/contracts/mocks/FuturesMock.sol b/contracts/contracts/mocks/FuturesMock.sol index bba7f76..c220e12 100644 --- a/contracts/contracts/mocks/FuturesMock.sol +++ b/contracts/contracts/mocks/FuturesMock.sol @@ -1,30 +1,28 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import { IFutures } from "../interfaces/IFutures.sol"; +import { ICollateralVault } from "../interfaces/ICollateralVault.sol"; +import { ILinearMarket } from "../interfaces/ILinearMarket.sol"; /// @title FuturesMock — Minimal mock of the Futures contract for PME tests /// @notice Direct analogue of `PerpsDEXMock`: lets tests pin the per-user -/// IFutures view outputs (`getNetPositionDelta`, -/// `getOrderMargin`, `getUnrealizedPnl`) and the -/// shared market price. All views default to zero so a fresh mock -/// is a no-op contributor to portfolio margin. -contract FuturesMock is IFutures { +/// ILinearMarket view outputs (`getNetPositionDelta`, the per-side order +/// delta / fill loss, `getUnrealizedPnl`). All views default to zero +/// so a fresh mock is a no-op contributor to portfolio margin. +contract FuturesMock is ILinearMarket { mapping(address => int256) private _netDelta; - mapping(address => uint256) private _orderMargin; mapping(address => int256) private _unrealizedPnl; - uint256 private _marketPrice; + 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; - function decimals() external pure returns (uint8) { - return 6; // USDC - } - - function setMarketPrice(uint256 price) external { - _marketPrice = price; - } + /// @dev See `PerpsDEXMock.vault`. Must be set before registering with a PME. + ICollateralVault public vault; - function getMarketPrice() external view returns (uint256) { - return _marketPrice; + function setVault(ICollateralVault _vault) external { + vault = _vault; } function setNetPositionDelta(address user, int256 delta) external { @@ -35,12 +33,16 @@ contract FuturesMock is IFutures { return _netDelta[user]; } - function setOrderMargin(address user, uint256 om) external { - _orderMargin[user] = om; + /// @dev Per-side order delta uses the same 10^collateralDecimals scale as + /// `netPositionDelta`; fill losses are token decimals. + function setOrderDeltas(address user, uint256 buyDelta, uint256 sellDelta) external { + _buyOrderDelta[user] = buyDelta; + _sellOrderDelta[user] = sellDelta; } - function getOrderMargin(address user) external view returns (uint256) { - return _orderMargin[user]; + function setOrderFillLosses(address user, uint256 buyLoss, uint256 sellLoss) external { + _buyOrderFillLoss[user] = buyLoss; + _sellOrderFillLoss[user] = sellLoss; } function setUnrealizedPnl(address user, int256 pnl) external { @@ -50,4 +52,26 @@ contract FuturesMock is IFutures { function getUnrealizedPnl(address user) external view returns (int256) { return _unrealizedPnl[user]; } + + /// @dev Real futures have no funding mechanism and always return 0; + /// settable here so tests can exercise the PME's funding path. + function setPendingFunding(address user, int256 pf) external { + _pendingFunding[user] = pf; + } + + function getPendingFunding(address user) external view returns (int256) { + return _pendingFunding[user]; + } + + function getRiskView(address user) external view returns (RiskView memory) { + return RiskView({ + netPositionDelta: _netDelta[user], + unrealizedPnl: _unrealizedPnl[user], + pendingFunding: _pendingFunding[user], + buyOrderDelta: _buyOrderDelta[user], + sellOrderDelta: _sellOrderDelta[user], + buyOrderFillLoss: _buyOrderFillLoss[user], + sellOrderFillLoss: _sellOrderFillLoss[user] + }); + } } diff --git a/contracts/contracts/mocks/MalformedProductMock.sol b/contracts/contracts/mocks/MalformedProductMock.sol new file mode 100644 index 0000000..7ec569e --- /dev/null +++ b/contracts/contracts/mocks/MalformedProductMock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title MalformedProductMock — answers every call with a single word +/// @notice Stands in for a contract that responds to the portfolio-margin selectors but +/// with the wrong return shape. Nothing about the call itself distinguishes it +/// from a healthy product; only decoding the answer against the expected return +/// type does, which is what the engine's registration checks rely on. +contract MalformedProductMock { + fallback(bytes calldata) external returns (bytes memory) { + return abi.encode(uint256(1)); + } +} diff --git a/contracts/contracts/mocks/MarginEngineMock.sol b/contracts/contracts/mocks/MarginEngineMock.sol index 5c5c09b..c66df2d 100644 --- a/contracts/contracts/mocks/MarginEngineMock.sol +++ b/contracts/contracts/mocks/MarginEngineMock.sol @@ -1,12 +1,22 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import { ICollateralVault } from "../interfaces/ICollateralVault.sol"; import { IPortfolioMarginEngine } from "../interfaces/IPortfolioMarginEngine.sol"; /// @title MarginEngineMock — Minimal mock for CollateralVault withdrawal checks contract MarginEngineMock is IPortfolioMarginEngine { + /// @dev The real engine pins this in its initializer; settable here so tests can + /// deploy the mock before the vault exists. Must be set before the mock is + /// handed to a product's `setPortfolioMargin`. + ICollateralVault public vault; + mapping(address => uint256) private _im; + function setVault(ICollateralVault _vault) external { + vault = _vault; + } + function setIM(address user, uint256 amount) external { _im[user] = amount; } @@ -20,6 +30,21 @@ contract MarginEngineMock is IPortfolioMarginEngine { return 0; } + /// @dev Consistent with the zero shock below: this mock never charges order margin. + function linearOrderMargin(uint256) external pure returns (uint256) { + return 0; + } + + /// @dev Same reasoning as `linearOrderMargin`: with no shock, resting orders are free. + function orderMarginOf(address) external pure returns (uint256) { + return 0; + } + + /// @dev Consistent with the zero shock: this mock models no resting orders at all. + function hasRestingOrderDelta(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 a998f2b..c2edb49 100644 --- a/contracts/contracts/mocks/OptionsEngineMock.sol +++ b/contracts/contracts/mocks/OptionsEngineMock.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import { ICollateralVault } from "../interfaces/ICollateralVault.sol"; import { IOptionsEnginePortfolioView } from "../interfaces/IOptionsEnginePortfolioView.sol"; /// @title OptionsEngineMock — Minimal mock for PortfolioMarginEngine tests @@ -14,6 +15,13 @@ contract OptionsEngineMock is IOptionsEnginePortfolioView { mapping(address => Greeks) private _greeks; mapping(address => uint256) private _reserved; + /// @dev See `PerpsDEXMock.vault`. Must be set before registering with a PME. + ICollateralVault public vault; + + function setVault(ICollateralVault _vault) external { + vault = _vault; + } + function setNetGreeks(address user, int256 delta, uint256 gamma, uint256 vega) external { _greeks[user] = Greeks(delta, gamma, vega); } diff --git a/contracts/contracts/mocks/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index 4bd4b18..1f89a35 100644 --- a/contracts/contracts/mocks/PerpsDEXMock.sol +++ b/contracts/contracts/mocks/PerpsDEXMock.sol @@ -1,32 +1,36 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import { IHashPowerPerpsDEX } from "../interfaces/IHashPowerPerpsDEX.sol"; +import { ICollateralVault } from "../interfaces/ICollateralVault.sol"; +import { ILinearMarket } from "../interfaces/ILinearMarket.sol"; /// @title PerpsDEXMock — Minimal mock of HashPowerPerpsDEX for options integration tests -contract PerpsDEXMock is IHashPowerPerpsDEX { +contract PerpsDEXMock is ILinearMarket { + struct Position { + int256 netQuantity; + uint256 aggregatedEntryPrice; + } + uint8 public constant QUANTITY_DECIMALS = 6; + /// @dev Real products pin this immutably at construction; settable here so tests can + /// deploy the mock before the vault exists. Must be set before the mock is + /// registered with a PortfolioMarginEngine. + ICollateralVault public vault; + + function setVault(ICollateralVault _vault) external { + vault = _vault; + } + mapping(address => Position) private _positions; mapping(address => uint256) private _balances; mapping(address => int256) private _unrealizedPnl; - mapping(address => uint256) private _initialMargin; mapping(address => uint256) private _maintenanceMargin; - mapping(address => uint256) private _orderMargin; mapping(address => int256) private _pendingFunding; - uint256 private _marketPrice; - - function decimals() external pure returns (uint8) { - return 6; // USDC - } - - function setMarketPrice(uint256 price) external { - _marketPrice = price; - } - - function getMarketPrice() external view returns (uint256) { - return _marketPrice; - } + mapping(address => uint256) private _buyOrderDelta; + mapping(address => uint256) private _sellOrderDelta; + mapping(address => uint256) private _buyOrderFillLoss; + mapping(address => uint256) private _sellOrderFillLoss; function setUserPosition(address user, int256 qty, uint256 entryPrice) external { _positions[user] = Position(qty, entryPrice); @@ -40,8 +44,8 @@ contract PerpsDEXMock is IHashPowerPerpsDEX { _unrealizedPnl[user] = pnl; } - function setMargins(address user, uint256 im, uint256 mm) external { - _initialMargin[user] = im; + /// @dev Only MM is modelled: it is the threshold `isLiquidatable` compares balance against. + function setMaintenanceMargin(address user, uint256 mm) external { _maintenanceMargin[user] = mm; } @@ -49,34 +53,48 @@ contract PerpsDEXMock is IHashPowerPerpsDEX { return _positions[user]; } - function getUnrealizedPnl(address user) external view returns (int256) { - return _unrealizedPnl[user]; - } - - function getInitialMargin(address user) external view returns (uint256) { - return _initialMargin[user]; + /// @dev Mirrors HashPowerPerpsDEX.getNetPositionDelta: qty scaled by + /// 10^collateralDecimals / 10^QUANTITY_DECIMALS (both 6 here). + function getNetPositionDelta(address user) external view returns (int256) { + return _positions[user].netQuantity * 1e6 / int256(10 ** QUANTITY_DECIMALS); } - function getMaintenanceMargin(address user) external view returns (uint256) { - return _maintenanceMargin[user]; + function getRiskView(address user) external view returns (RiskView memory) { + return RiskView({ + netPositionDelta: _positions[user].netQuantity * 1e6 / int256(10 ** QUANTITY_DECIMALS), + unrealizedPnl: _unrealizedPnl[user], + pendingFunding: _pendingFunding[user], + buyOrderDelta: _buyOrderDelta[user], + sellOrderDelta: _sellOrderDelta[user], + buyOrderFillLoss: _buyOrderFillLoss[user], + sellOrderFillLoss: _sellOrderFillLoss[user] + }); } - function getOrderMargin(address user) external view returns (uint256) { - return _orderMargin[user]; + function getUnrealizedPnl(address user) external view returns (int256) { + return _unrealizedPnl[user]; } function getPendingFunding(address user) external view returns (int256) { return _pendingFunding[user]; } - function setOrderMargin(address user, uint256 om) external { - _orderMargin[user] = om; - } - function setPendingFunding(address user, int256 pf) external { _pendingFunding[user] = pf; } + /// @dev Per-side order delta uses the same 10^collateralDecimals scale as + /// `netPositionDelta`; fill losses are token decimals. + function setOrderDeltas(address user, uint256 buyDelta, uint256 sellDelta) external { + _buyOrderDelta[user] = buyDelta; + _sellOrderDelta[user] = sellDelta; + } + + function setOrderFillLosses(address user, uint256 buyLoss, uint256 sellLoss) external { + _buyOrderFillLoss[user] = buyLoss; + _sellOrderFillLoss[user] = sellLoss; + } + function isLiquidatable(address user) external view returns (bool) { if (_positions[user].netQuantity == 0) return false; return _balances[user] < _maintenanceMargin[user]; diff --git a/contracts/contracts/mocks/PriceOracleMock.sol b/contracts/contracts/mocks/PriceOracleMock.sol new file mode 100644 index 0000000..b7dd5fd --- /dev/null +++ b/contracts/contracts/mocks/PriceOracleMock.sol @@ -0,0 +1,66 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title PriceOracleMock +/// @notice Mock price oracle for testing that properly handles timestamps +contract PriceOracleMock { + uint8 private _decimals; + int256 private _price; + string private _description = "Price Oracle Mock"; + uint256 private _frozenTimestamp; // If non-zero, use this instead of block.timestamp + + constructor(int256 initialPrice, uint8 decimals_) { + _price = initialPrice; + _decimals = decimals_; + } + + function decimals() external view returns (uint8) { + return _decimals; + } + + function description() external view returns (string memory) { + return _description; + } + + function version() external pure returns (uint256) { + return 1; + } + + function _getUpdatedAt() private view returns (uint256) { + return _frozenTimestamp > 0 ? _frozenTimestamp : block.timestamp; + } + + function getRoundData(uint80) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + uint256 ts = _getUpdatedAt(); + return (0, _price, ts, ts, 0); + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + uint256 ts = _getUpdatedAt(); + return (0, _price, ts, ts, 0); + } + + function setPrice(int256 price, uint8 decimals_) external { + _price = price; + _decimals = decimals_; + } + + /// @notice Freeze the timestamp at the current block.timestamp + /// @dev After calling this, advancing time will make the oracle appear stale + function freezeTimestamp() external { + _frozenTimestamp = block.timestamp; + } + + /// @notice Unfreeze the timestamp to always return current block.timestamp + function unfreezeTimestamp() external { + _frozenTimestamp = 0; + } +} diff --git a/contracts/scripts/deploy-portfolio-margin-engine.ts b/contracts/scripts/deploy-portfolio-margin-engine.ts index 3cc6224..9168527 100644 --- a/contracts/scripts/deploy-portfolio-margin-engine.ts +++ b/contracts/scripts/deploy-portfolio-margin-engine.ts @@ -17,6 +17,7 @@ async function main() { const PERPS_ADDRESS = readOptionalAddress("PERPS_ADDRESS"); const OPTIONS_ENGINE_ADDRESS = readOptionalAddress("OPTIONS_ENGINE_ADDRESS"); const FUTURES_ADDRESS = readOptionalAddress("FUTURES_ADDRESS"); + const PRICE_ORACLE_ADDRESS = readOptionalAddress("PRICE_ORACLE_ADDRESS"); const imSpotShock = readOptionalBigInt("IM_SPOT_SHOCK"); const mmSpotShock = readOptionalBigInt("MM_SPOT_SHOCK"); @@ -47,6 +48,7 @@ async function main() { Perps: PERPS_ADDRESS ?? "(none)", Options: OPTIONS_ENGINE_ADDRESS ?? "(none)", Futures: FUTURES_ADDRESS ?? "(none)", + PriceOracle: PRICE_ORACLE_ADDRESS ?? "(none)", }); if (overrideShocks) { @@ -127,9 +129,9 @@ async function main() { // ── 4. Register product engines on PME (optional) ─────────────────────── if (PERPS_ADDRESS) { - logInfo("PME.setPerps", { perpsDex: PERPS_ADDRESS }); + logInfo("PME.addLinearMarket (perps)", { market: PERPS_ADDRESS }); await logPrompt("Proceed?"); - const sim = await pme.simulate.setPerps([PERPS_ADDRESS]); + const sim = await pme.simulate.addLinearMarket([PERPS_ADDRESS]); const receipt = await writeAndWait(deployer, sim); logStep("Done", txUrl(pc, receipt.transactionHash)); } @@ -141,9 +143,16 @@ async function main() { logStep("Done", txUrl(pc, receipt.transactionHash)); } if (FUTURES_ADDRESS) { - logInfo("PME.setFutures", { futures: FUTURES_ADDRESS }); + logInfo("PME.addLinearMarket (futures)", { market: FUTURES_ADDRESS }); await logPrompt("Proceed?"); - const sim = await pme.simulate.setFutures([FUTURES_ADDRESS]); + const sim = await pme.simulate.addLinearMarket([FUTURES_ADDRESS]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + if (PRICE_ORACLE_ADDRESS) { + logInfo("PME.setOracle", { oracle: PRICE_ORACLE_ADDRESS }); + await logPrompt("Proceed?"); + const sim = await pme.simulate.setOracle([PRICE_ORACLE_ADDRESS]); const receipt = await writeAndWait(deployer, sim); logStep("Done", txUrl(pc, receipt.transactionHash)); } diff --git a/contracts/scripts/update-portfolio-margin-engine.ts b/contracts/scripts/update-portfolio-margin-engine.ts index 3977d97..14df02c 100644 --- a/contracts/scripts/update-portfolio-margin-engine.ts +++ b/contracts/scripts/update-portfolio-margin-engine.ts @@ -1,6 +1,6 @@ import { encodeFunctionData, getAddress } from "viem"; import hre from "hardhat"; -import { requireAddress } from "../lib/env.ts"; +import { readOptionalAddress, requireAddress } from "../lib/env.ts"; import { writeAndWait } from "../lib/writeContract.ts"; import { verifyContract } from "../lib/verify.ts"; import { addrUrl, txUrl } from "../lib/explorer.ts"; @@ -12,6 +12,9 @@ async function main() { const { viem } = await hre.network.getOrCreate(); const proxyAddress = requireAddress("PME_ADDRESS"); + // Optional: post-upgrade oracle configuration. The new implementation reverts + // margin calls with `OracleNotSet` until the PME has its own oracle reference. + const PRICE_ORACLE_ADDRESS = readOptionalAddress("PRICE_ORACLE_ADDRESS"); const [deployer] = await viem.getWalletClients(); const pc = await viem.getPublicClient(); @@ -52,6 +55,14 @@ async function main() { logStep("Upgraded", txUrl(pc, receipt.transactionHash)); logInfo("post-upgrade", { Version: await pme.read.VERSION() }); + + if (PRICE_ORACLE_ADDRESS) { + logInfo("PME.setOracle", { oracle: PRICE_ORACLE_ADDRESS }); + await logPrompt("Proceed?"); + const oracleSim = await pme.simulate.setOracle([PRICE_ORACLE_ADDRESS]); + const oracleReceipt = await writeAndWait(deployer, oracleSim); + logStep("Done", txUrl(pc, oracleReceipt.transactionHash)); + } } else { const calldata = encodeFunctionData({ abi: pme.abi, @@ -63,6 +74,15 @@ async function main() { "Owner (from)": owner, }); logStep(`PME.upgradeToAndCall(${newImpl.address}, 0x)`, calldata); + + if (PRICE_ORACLE_ADDRESS) { + const oracleData = encodeFunctionData({ + abi: pme.abi, + functionName: "setOracle", + args: [PRICE_ORACLE_ADDRESS], + }); + logStep(`PME.setOracle(${PRICE_ORACLE_ADDRESS})`, oracleData); + } } logSuccess(addrUrl(pc, proxyAddress)); diff --git a/contracts/tests/collateralVault.test.ts b/contracts/tests/collateralVault.test.ts index 5fa400a..7c94ec7 100644 --- a/contracts/tests/collateralVault.test.ts +++ b/contracts/tests/collateralVault.test.ts @@ -4,11 +4,13 @@ import { getAddress, maxUint256, zeroAddress } from "viem"; import { network } from "hardhat"; import { VAULT_AUTH_OPS_ALICE_DEPOSIT, + deployCollateralVaultProxy, deployVaultAuthorizedOperationsFixture, deployVaultFixture, } from "./fixtures.js"; -const { viem, networkHelpers } = await network.connect(); +const conn = await network.connect(); +const { viem, networkHelpers } = conn; /** 1 USDC (6 decimals). Shared across deposit / access-control setup tests. */ const ONE_USDC = 1_000_000n; @@ -136,9 +138,63 @@ describe("CollateralVault", () => { // ── Margin-gated withdrawal ───────────────────────────────────────────── + describe("margin engine wiring", () => { + it("rejects an address holding no code", async () => { + const { vault, bob } = await networkHelpers.loadFixture(deployVaultFixture); + + await viem.assertions.revertWithCustomError( + vault.write.setMarginEngine([bob.account.address]), + vault, + "InvalidDependency", + ); + }); + + it("rejects a contract lacking the margin-engine surface", async () => { + const { vault, usdc } = await networkHelpers.loadFixture(deployVaultFixture); + + await viem.assertions.revertWithCustomError( + vault.write.setMarginEngine([usdc.address]), + vault, + "InvalidDependency", + ); + }); + + it("rejects an engine aggregating a different vault", async () => { + const { vault } = await networkHelpers.loadFixture(deployVaultFixture); + const { vault: otherVault } = await deployCollateralVaultProxy(conn); + const strayEngine = await viem.deployContract("MarginEngineMock", []); + await strayEngine.write.setVault([otherVault.address]); + + await viem.assertions.revertWithCustomError( + vault.write.setMarginEngine([strayEngine.address], ), + vault, + "VaultMismatch", + ); + }); + + it("accepts an engine aggregating this vault", async () => { + const { vault } = await networkHelpers.loadFixture(deployVaultFixture); + const engine = await viem.deployContract("MarginEngineMock", []); + await engine.write.setVault([vault.address]); + + await vault.write.setMarginEngine([engine.address], ); + assert.equal(await vault.read.marginEngine(), getAddress(engine.address)); + }); + + it("still allows clearing the engine to ungate withdrawals", async () => { + const { vault } = await networkHelpers.loadFixture(deployVaultFixture); + const engine = await viem.deployContract("MarginEngineMock", []); + await engine.write.setVault([vault.address]); + await vault.write.setMarginEngine([engine.address], ); + + await vault.write.setMarginEngine([zeroAddress], ); + assert.equal(await vault.read.marginEngine(), zeroAddress); + }); + }); + describe("margin-gated withdrawal", () => { it("blocks withdrawal that would breach margin", async () => { - const { vault, owner, alice } = await networkHelpers.loadFixture(deployVaultFixture); + const { vault, alice } = await networkHelpers.loadFixture(deployVaultFixture); const aliceDeposit = 10_000_000n; const requiredIm = 8_000_000n; const withdrawAmount = 2_000_000n; @@ -146,8 +202,10 @@ describe("CollateralVault", () => { await vault.write.deposit([aliceDeposit], { account: alice.account }); const mock = await viem.deployContract("MarginEngineMock", []); + // The vault only adopts an engine that aggregates it. + await mock.write.setVault([vault.address]); await viem.assertions.emitWithArgs( - vault.write.setMarginEngine([mock.address], { account: owner.account }), + vault.write.setMarginEngine([mock.address], ), vault, "MarginEngineSet", [getAddress(mock.address)], diff --git a/contracts/tests/crossMarginIntegration.test.ts b/contracts/tests/crossMarginIntegration.test.ts index 14872e0..19c7413 100644 --- a/contracts/tests/crossMarginIntegration.test.ts +++ b/contracts/tests/crossMarginIntegration.test.ts @@ -125,7 +125,8 @@ describe("Cross-Margin Integration", () => { deployCrossMarginIntegrationFixture, ); - await perpsMock.write.setOrderMargin([aliceAddr, 5_000_000_000n]); + // 1 lot of resting bids on a flat account → 1e6 × 10% × $50k = $5,000 of stress. + await perpsMock.write.setOrderDeltas([aliceAddr, 1_000_000n, 0n]); await optionsMock.write.setReservedMargin([aliceAddr, 3_000_000_000n * 10n ** 12n]); await perpsMock.write.setUnrealizedPnl([aliceAddr, -2_000_000_000n]); await perpsMock.write.setPendingFunding([aliceAddr, 1_000_000_000n]); @@ -199,7 +200,8 @@ describe("Cross-Margin Integration", () => { const maxWithdraw = 5_000_000_000n; const excessWithdrawAttempt = 10_000_000_000n; - await perpsMock.write.setOrderMargin([aliceAddr, orderMargin]); + // 9 lots of resting bids → 9e6 × 10% × $50k = $45,000 of stress on the buy leg. + await perpsMock.write.setOrderDeltas([aliceAddr, 9_000_000n, 0n]); await viem.assertions.revertWithCustomError( vault.write.withdraw([excessWithdrawAttempt], { account: alice.account }), @@ -219,12 +221,12 @@ describe("Cross-Margin Integration", () => { }); describe("futures leg in cross-margin engine", () => { - // Each futures contract over D delivery days contributes delta = D * WAD; - // the FuturesMock returns whatever delta the test pins. A delta of 7e18 - // (one 7-day contract) at $50k spot with 10% IM stress = $35k loss in WAD, - // i.e. 35_000_000_000 token units (6 decimals). - const ONE_WEEK_DAYS_WAD = 7n * 10n ** 18n; - const SEVEN_DAY_LONG_IM = 35_000_000_000n; // |7e18| * 10% * $50k → 35k USDC + // Deltas are pinned in the ILinearMarket token-decimal scale (10^6, USDC); + // the PME lifts them to its internal WAD scale. A delta of 7e6 (7 contracts) + // at $50k spot with 10% IM stress = $35k loss, i.e. 35_000_000_000 token + // units (6 decimals). + const ONE_WEEK_DELTA = 7n * 10n ** 6n; + const SEVEN_DAY_LONG_IM = 35_000_000_000n; // |7e6| * 10% * $50k → 35k USDC const SEVEN_DAY_LONG_MM = 17_500_000_000n; // 5% MM = 17.5k USDC it("futures-only IM gates withdrawal", async () => { @@ -232,9 +234,9 @@ describe("Cross-Margin Integration", () => { deployCrossMarginIntegrationFixture, ); - // Alice has 50k USDC. One 7-day long contract → IM = $35k. + // Alice has 50k USDC. One 7-contract long → IM = $35k. // Withdrawing 30k would leave 20k < 35k IM, so it must revert. - await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DAYS_WAD]); + await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DELTA]); await viem.assertions.revertWithCustomError( vault.write.withdraw([30_000_000_000n], { account: alice.account }), @@ -251,7 +253,7 @@ describe("Cross-Margin Integration", () => { const withdrawAmount = 40_000_000_000n; // Pure futures short → IM = $35k → withdraw 40k must fail. - await futuresMock.write.setNetPositionDelta([aliceAddr, -ONE_WEEK_DAYS_WAD]); + await futuresMock.write.setNetPositionDelta([aliceAddr, -ONE_WEEK_DELTA]); await viem.assertions.revertWithCustomError( vault.write.withdraw([withdrawAmount], { account: alice.account }), vault, @@ -261,8 +263,8 @@ describe("Cross-Margin Integration", () => { // Add a perp long that offsets the futures short delta-for-delta. With // net portfolio delta ≈ 0 the stress loss collapses, so 40k withdraw // succeeds (only the perp's order/position add-ons remain — both zero). - // Perp delta = qty * WAD / 10^QUANTITY_DECIMALS. To offset 7e18 we need - // qty = 7e18 * 1e6 / 1e18 = 7_000_000. + // Perp delta = qty * 10^6 / 10^QUANTITY_DECIMALS = qty (both 6 decimals), + // so qty = 7_000_000 offsets the 7e6 futures delta. await perpsMock.write.setUserPosition([aliceAddr, 7_000_000n, DEFAULT_MARKET_PRICE]); await viem.assertions.emitWithArgs( @@ -273,22 +275,40 @@ describe("Cross-Margin Integration", () => { ); }); - it("aggregates futures order margin + futures unrealized loss + perps order margin", async () => { + it("sums same-side order delta across futures and perps before stressing", async () => { const { pme, perpsMock, futuresMock, aliceAddr } = await networkHelpers.loadFixture( deployCrossMarginIntegrationFixture, ); - const futuresOrder = 4_000_000_000n; const futuresLoss = -2_500_000_000n; - const perpsOrder = 1_500_000_000n; - - await futuresMock.write.setOrderMargin([aliceAddr, futuresOrder]); + // 0.8e6 futures + 0.3e6 perps of resting bid delta → 1.1e6 × 10% × $50k = $5,500. + await futuresMock.write.setOrderDeltas([aliceAddr, 800_000n, 0n]); await futuresMock.write.setUnrealizedPnl([aliceAddr, futuresLoss]); - await perpsMock.write.setOrderMargin([aliceAddr, perpsOrder]); + await perpsMock.write.setOrderDeltas([aliceAddr, 300_000n, 0n]); const im = await pme.read.computePortfolioIM([aliceAddr]); - // No net delta → no stress loss; only the linear add-ons aggregate. - assert.equal(im, futuresOrder + perpsOrder + 2_500_000_000n, "linear components add"); + assert.equal(im, 5_500_000_000n + 2_500_000_000n, "one stress leg over the summed delta"); + }); + + it("charges a perps ask that a futures long makes risk-increasing at the portfolio", async () => { + const { pme, perpsMock, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + // Long 1e6 futures, short 1e6 perps: flat at the portfolio, so no stress. + await futuresMock.write.setNetPositionDelta([aliceAddr, 1_000_000n]); + await perpsMock.write.setUserPosition([aliceAddr, -1_000_000n, DEFAULT_MARKET_PRICE]); + assert.equal(await pme.read.computePortfolioIM([aliceAddr]), 0n, "hedged portfolio is flat"); + + // A resting perps ask looks risk-reducing to nobody once netted: filling it takes + // the portfolio to genuinely short 1e6. The old per-venue credit charged 0 here. + await perpsMock.write.setOrderDeltas([aliceAddr, 0n, 1_000_000n]); + assert.equal( + await pme.read.computePortfolioIM([aliceAddr]), + 5_000_000_000n, + "sell leg stresses the post-fill short", + ); + assert.equal(await pme.read.orderMarginOf([aliceAddr]), 5_000_000_000n); }); it("PME isHealthy reflects futures-driven MM breach", async () => { @@ -298,14 +318,14 @@ describe("Cross-Margin Integration", () => { assert.equal(await pme.read.isHealthy([aliceAddr]), true, "no positions = healthy"); - // 7-day long → MM = 5% * $50k = $17.5k (well under 50k balance). - await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DAYS_WAD]); + // 7-contract long → MM = 5% * $50k = $17.5k (well under 50k balance). + await futuresMock.write.setNetPositionDelta([aliceAddr, ONE_WEEK_DELTA]); assert.equal(await pme.read.isHealthy([aliceAddr]), true, "small futures MM still healthy"); assert.ok(SEVEN_DAY_LONG_MM < INTEGRATION_ALICE_DEPOSIT); assert.ok(SEVEN_DAY_LONG_IM < INTEGRATION_ALICE_DEPOSIT); - // Scale the delta until MM exceeds 50k. 5e19 delta * 5% * 50k = 125k WAD = $125k. - await futuresMock.write.setNetPositionDelta([aliceAddr, 5n * 10n ** 19n]); + // Scale the delta until MM exceeds 50k. 5e7 delta * 5% * $50k = $125k. + await futuresMock.write.setNetPositionDelta([aliceAddr, 5n * 10n ** 7n]); assert.equal( await pme.read.isHealthy([aliceAddr]), false, diff --git a/contracts/tests/fixtures.ts b/contracts/tests/fixtures.ts index 6fc44e6..4bb4ef1 100644 --- a/contracts/tests/fixtures.ts +++ b/contracts/tests/fixtures.ts @@ -1,7 +1,7 @@ import type { NetworkConnection } from "hardhat/types/network"; import { encodeFunctionData, maxUint256 } from "viem"; -/** Spot price used in PerpsDEXMock across margin tests ($50k in token decimals). */ +/** Index price used by the PME oracle mock and as perps mock entry price ($50k, token decimals). */ export const DEFAULT_MARKET_PRICE = 50_000_000_000n; const VAULT_TEST_TOP_UP = 100_000_000_000n; // 100k USDC for alice, bob, engine @@ -37,24 +37,30 @@ export async function deployPortfolioMarginEngineStack( ) { const { viem } = conn; const perpsMock = await viem.deployContract("PerpsDEXMock", []); - await perpsMock.write.setMarketPrice([DEFAULT_MARKET_PRICE]); const optionsMock = await viem.deployContract("OptionsEngineMock", []); const futuresMock = await viem.deployContract("FuturesMock", []); - await futuresMock.write.setMarketPrice([DEFAULT_MARKET_PRICE]); + // PME's own index oracle — spot source for stress math (6 decimals, $50k). + const oracleMock = await viem.deployContract("PriceOracleMock", [DEFAULT_MARKET_PRICE, 6]); const pmeImpl = await viem.deployContract("PortfolioMarginEngine", []); const pmeProxy = await viem.deployContract("ERC1967Proxy", [ pmeImpl.address as `0x${string}`, encodeFunctionData({ abi: pmeImpl.abi, functionName: "initialize", - args: [vaultAddress], + args: [], }), ]); const pme = await viem.getContractAt("PortfolioMarginEngine", pmeProxy.address); - await pme.write.setPerps([perpsMock.address]); + // The PME pins each product to its own vault at registration. + await perpsMock.write.setVault([vaultAddress]); + await futuresMock.write.setVault([vaultAddress]); + await optionsMock.write.setVault([vaultAddress]); + await pme.write.setVault([vaultAddress]); + await pme.write.addLinearMarket([perpsMock.address]); + await pme.write.addLinearMarket([futuresMock.address]); await pme.write.setOptions([optionsMock.address]); - await pme.write.setFutures([futuresMock.address]); - return { perpsMock, optionsMock, futuresMock, pme }; + await pme.write.setOracle([oracleMock.address]); + return { perpsMock, optionsMock, futuresMock, oracleMock, pme }; } /** CollateralVault tests: fund alice, bob, engine; approvals for deposit flows. */ diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index 8ecad07..ed2bb2b 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -1,9 +1,15 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { network } from "hardhat"; -import { DEFAULT_MARKET_PRICE, deployPortfolioMarginEngineFixture } from "./fixtures.js"; +import { getAddress, zeroAddress } from "viem"; +import { + DEFAULT_MARKET_PRICE, + deployCollateralVaultProxy, + deployPortfolioMarginEngineFixture, +} from "./fixtures.js"; -const { viem, networkHelpers } = await network.connect(); +const conn = await network.connect(); +const { viem, networkHelpers } = conn; /** Perps mock: 1-lot quantity (1e6 units). Used wherever tests open a one-lot position. */ const ONE_LOT_QTY = 1_000_000n; @@ -68,6 +74,86 @@ describe("PortfolioMarginEngine", () => { assert.equal(imWithProfit, imBase, "unrealized profit does not change IM"); }); + /** + * IM clamps unrealized PnL per market so gains are ignored; MM clamps the + * portfolio-wide sum so a gain at one venue offsets a loss at another. The + * split lets a cross-venue hedge stay solvent without letting an unrealized + * gain release collateral through the vault's IM-gated withdrawal check. + */ + describe("cross-market unrealized PnL clamp", () => { + const AMOUNT = 1_000_000_000n; + + /** Offsetting marks: perps down $1,000, futures up the same. */ + async function offsettingPnl() { + const fixture = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + await fixture.perpsMock.write.setUnrealizedPnl([fixture.user, -AMOUNT]); + await fixture.futuresMock.write.setUnrealizedPnl([fixture.user, AMOUNT]); + return fixture; + } + + it("MM lets a gain at one venue offset a loss at another", async () => { + const { pme, user } = await offsettingPnl(); + + assert.equal( + await pme.read.computePortfolioMM([user]), + 0n, + "net PnL is zero, so MM carries no unrealized term at all", + ); + }); + + it("IM ignores the offsetting gain and charges the loss in full", async () => { + const { pme, user } = await offsettingPnl(); + + assert.equal( + await pme.read.computePortfolioIM([user]), + AMOUNT, + "IM gates withdrawals, so an unrealized gain must not release collateral", + ); + }); + + it("MM still charges a net loss in full", async () => { + const { pme, perpsMock, futuresMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + await perpsMock.write.setUnrealizedPnl([user, -AMOUNT]); + await futuresMock.write.setUnrealizedPnl([user, AMOUNT / 4n]); + + assert.equal( + await pme.read.computePortfolioMM([user]), + AMOUNT - AMOUNT / 4n, + "netting reduces the charge to the residual, not below it", + ); + }); + + it("a net gain cannot reduce MM below the rest of the requirement", async () => { + const { pme, perpsMock, futuresMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + const mmBase = await pme.read.computePortfolioMM([user]); + + // Overwhelming profit on one leg, none on the other. + await futuresMock.write.setUnrealizedPnl([user, 100n * AMOUNT]); + + assert.equal( + await pme.read.computePortfolioMM([user]), + mmBase, + "unrealized profit offsets losses but never funds a discount on stress", + ); + }); + + it("keeps IM at or above MM, which OverLiquidation depends on", async () => { + const { pme, perpsMock, futuresMock, user } = await offsettingPnl(); + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + await futuresMock.write.setNetPositionDelta([user, -ONE_LOT_QTY / 2n]); + + assert.ok( + (await pme.read.computePortfolioIM([user])) >= (await pme.read.computePortfolioMM([user])), + "the venues' over-liquidation guard is unsound if IM can dip below MM", + ); + }); + }); + it("includes pending funding owed in margin", async () => { const { pme, perpsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, @@ -83,16 +169,180 @@ describe("PortfolioMarginEngine", () => { assert.equal(imWithFunding - imBase, fundingOwed, "funding owed adds to IM"); }); - it("includes perps order margin", async () => { + it("stresses resting bids as post-fill delta", async () => { + const { pme, perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + // 0.4 lots of resting bids on a flat account: the buy leg stresses at + // 4e5 delta × 10% × $50k = $2,000. + const buyDelta = 400_000n; + + await perpsMock.write.setOrderDeltas([user, buyDelta, 0n]); + const im = await pme.read.computePortfolioIM([user]); + + assert.equal(im, 2_000_000_000n, "resting bid delta drives the worse stress leg"); + }); + + it("takes the worse of the two fill legs", async () => { const { pme, perpsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, ); - const orderMargin = 2_000_000_000n; + // Flat account, asks twice the size of the bids: the sell leg is worse. + await perpsMock.write.setOrderDeltas([user, 400_000n, 800_000n]); + const im = await pme.read.computePortfolioIM([user]); + + assert.equal(im, 4_000_000_000n, "|-8e5| stress dominates |+4e5|"); + }); + + it("nets a resting ask against a long position instead of crediting it", async () => { + const { pme, perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + // Long 1 lot with 1 lot of resting asks: filling them takes the account flat, + // so the sell leg is free and the (empty) buy leg is what the position costs. + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + await perpsMock.write.setOrderDeltas([user, 0n, ONE_LOT_QTY]); - await perpsMock.write.setOrderMargin([user, orderMargin]); const im = await pme.read.computePortfolioIM([user]); + assert.equal(im, 5_000_000_000n, "position stress only; the offsetting ask adds nothing"); + + // Twice the position in resting asks flips the account net short on a fill — + // the sell leg now dominates and the order is charged rather than credited. + await perpsMock.write.setOrderDeltas([user, 0n, 2n * ONE_LOT_QTY]); + const imOverSold = await pme.read.computePortfolioIM([user]); + assert.equal(imOverSold, 5_000_000_000n, "net short 1 lot costs the same as net long 1 lot"); + }); + + it("adds per-side fill loss on top of both legs", async () => { + const { pme, perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + const buyLoss = 700_000_000n; + const sellLoss = 300_000_000n; + + await perpsMock.write.setOrderDeltas([user, 400_000n, 0n]); + await perpsMock.write.setOrderFillLosses([user, buyLoss, sellLoss]); + + const im = await pme.read.computePortfolioIM([user]); + assert.equal(im, 2_000_000_000n + buyLoss + sellLoss, "both sides' fill loss is charged"); + }); + + it("orderMarginOf reports the incremental cost of the resting orders", async () => { + const { pme, perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + assert.equal(await pme.read.orderMarginOf([user]), 0n, "no orders cost nothing"); + + // An ask that exactly offsets the long is free; the same ask doubled costs the + // difference between net short 1 lot and net long 1 lot, i.e. nothing either. + await perpsMock.write.setOrderDeltas([user, 0n, ONE_LOT_QTY]); + assert.equal(await pme.read.orderMarginOf([user]), 0n, "offsetting ask is free"); + + // A bid on top of the long is charged in full. + await perpsMock.write.setOrderDeltas([user, ONE_LOT_QTY, 0n]); + assert.equal( + await pme.read.orderMarginOf([user]), + 5_000_000_000n, + "adding-to-position bid costs its own stress", + ); + }); + }); + + describe("hasRestingOrderDelta", () => { + it("is false for an account with positions but no orders", async () => { + const { pme, perpsMock, futuresMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + await futuresMock.write.setNetPositionDelta([user, ONE_LOT_QTY]); + + assert.equal( + await pme.read.hasRestingOrderDelta([user]), + false, + "position delta is not order delta — only resting orders gate liquidation", + ); + }); + + it("sees order delta on a venue other than the one asking", async () => { + const { pme, perpsMock, futuresMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + // The case the gate exists for: the position is on one venue, the resting + // orders on another, and neither venue can see the other's book. + await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + await futuresMock.write.setOrderDeltas([user, 0n, ONE_LOT_QTY]); + + assert.equal(await pme.read.hasRestingOrderDelta([user]), true); + }); + + it("catches either side", async () => { + const { pme, perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setOrderDeltas([user, ONE_LOT_QTY, 0n]); + assert.equal(await pme.read.hasRestingOrderDelta([user]), true, "bids count"); + + await perpsMock.write.setOrderDeltas([user, 0n, ONE_LOT_QTY]); + assert.equal(await pme.read.hasRestingOrderDelta([user]), true, "asks count"); + + await perpsMock.write.setOrderDeltas([user, 0n, 0n]); + assert.equal(await pme.read.hasRestingOrderDelta([user]), false, "cleared book reads false"); + }); + }); + + /** + * Which leg a liquidator closes is not neutral. Net delta is what gets stressed, + * so closing the leg that opposes it widens the requirement while closing the + * leg that dominates it narrows one. Both are reachable from the same account — + * the venues cannot tell them apart, because neither can see the other's book. + */ + describe("cross-venue hedge: liquidation leg selection", () => { + /** Perps long 1 lot against a futures short of 2 lots — net short 1 lot. */ + async function hedgedAccount() { + const fixture = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + await fixture.perpsMock.write.setUserPosition([ + fixture.user, + ONE_LOT_QTY, + DEFAULT_MARKET_PRICE, + ]); + await fixture.futuresMock.write.setNetPositionDelta([fixture.user, -2n * ONE_LOT_QTY]); + return fixture; + } + + it("closing the opposing leg widens the requirement", async () => { + const { pme, perpsMock, user } = await hedgedAccount(); + + const mmHedged = await pme.read.computePortfolioMM([user]); + + // The perps long was offsetting half the futures short. Closing it in full + // takes net delta from -1 lot to -2, doubling the stressed exposure. + await perpsMock.write.setUserPosition([user, 0n, 0n]); + + assert.ok( + (await pme.read.computePortfolioMM([user])) > mmHedged, + "liquidating the hedge leg must raise MM — this is the harmful choice", + ); + }); + + it("a leg that reduces net exposure always exists", async () => { + const { pme, futuresMock, user } = await hedgedAccount(); + + const mmHedged = await pme.read.computePortfolioMM([user]); + + // The dominant side is the futures short. Trimming it to match the perps + // long flattens the portfolio, which is the move a liquidator should make. + await futuresMock.write.setNetPositionDelta([user, -ONE_LOT_QTY]); - assert.equal(im, orderMargin, "order margin adds to IM"); + assert.ok( + (await pme.read.computePortfolioMM([user])) < mmHedged, + "trimming the dominant leg must lower MM — partial liquidation is not " + + "inherently worsening, the choice of leg is what decides it", + ); }); }); @@ -169,6 +419,30 @@ describe("PortfolioMarginEngine", () => { }); }); + describe("linearOrderMargin", () => { + it("applies the IM spot shock to a notional, in token decimals", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + const notional = 50_000_000_000n; + const shock = await pme.read.imSpotShock(); + + assert.equal(await pme.read.linearOrderMargin([notional]), (notional * shock) / 10n ** 18n); + assert.equal(await pme.read.linearOrderMargin([0n]), 0n); + }); + + it("tracks the shock when the owner updates it", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + const notional = 50_000_000_000n; + const before = await pme.read.linearOrderMargin([notional]); + + const shocks = [0.2e18, 0.1e18, 0.1e18, 0.05e18].map(BigInt) as [bigint, bigint, bigint, bigint]; + await pme.write.setShocks(shocks); + + assert.equal(await pme.read.linearOrderMargin([notional]), before * 2n); + }); + }); + describe("admin", () => { it("owner can update shocks", async () => { const { pme, perpsMock, user } = await networkHelpers.loadFixture( @@ -191,6 +465,160 @@ describe("PortfolioMarginEngine", () => { }); }); + describe("dependency validation", () => { + it("rejects a linear market that is not a contract", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const [, eoa] = await viem.getWalletClients(); + + await viem.assertions.revertWithCustomError( + pme.write.addLinearMarket([eoa.account.address]), + pme, + "InvalidDependency", + ); + }); + + it("rejects a linear market lacking getRiskView", async () => { + const { pme, usdc } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await viem.assertions.revertWithCustomError( + pme.write.addLinearMarket([usdc.address]), + pme, + "InvalidDependency", + ); + }); + + it("rejects a market whose getRiskView returns the wrong shape", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + // Answers every selector with one word, so only decoding against RiskView + // (seven words) can catch it. The decode happens outside the engine's catch block, + // so this is the one case that escapes InvalidDependency as a bare revert. + const malformed = await viem.deployContract("MalformedProductMock", []); + + await viem.assertions.revertWithCustomError(pme.write.addLinearMarket([malformed.address]), pme, "VaultMismatch"); + }); + + it("rejects a linear market settling into a different vault", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const strayMarket = await viem.deployContract("PerpsDEXMock", []); + const { vault: otherVault } = await deployCollateralVaultProxy(conn); + await strayMarket.write.setVault([otherVault.address]); + + await viem.assertions.revertWithCustomError( + pme.write.addLinearMarket([strayMarket.address]), + pme, + "VaultMismatch", + ); + }); + + it("rejects a linear market with no vault pinned at all", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const unpinnedMarket = await viem.deployContract("FuturesMock", []); + + await viem.assertions.revertWithCustomError( + pme.write.addLinearMarket([unpinnedMarket.address]), + pme, + "VaultMismatch", + ); + }); + + it("rejects swapping the vault while a market pins the old one", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const { vault: newVault } = await deployCollateralVaultProxy(conn); + + await viem.assertions.revertWithCustomError( + pme.write.setVault([newVault.address]), + pme, + "VaultMismatch", + ); + }); + + it("allows swapping the vault once the stale products are deregistered", async () => { + const { pme, perpsMock, futuresMock } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + const { vault: newVault } = await deployCollateralVaultProxy(conn); + + await pme.write.removeLinearMarket([perpsMock.address]); + await pme.write.removeLinearMarket([futuresMock.address]); + await pme.write.setOptions([zeroAddress]); + + await pme.write.setVault([newVault.address]); + assert.equal(await pme.read.vault(), getAddress(newVault.address)); + }); + + it("rejects an options engine settling into a different vault", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const strayEngine = await viem.deployContract("OptionsEngineMock", []); + const { vault: otherVault } = await deployCollateralVaultProxy(conn); + await strayEngine.write.setVault([otherVault.address]); + + await viem.assertions.revertWithCustomError( + pme.write.setOptions([strayEngine.address]), + pme, + "VaultMismatch", + ); + }); + + it("rejects an options engine lacking the Greeks surface", async () => { + const { pme, usdc } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await viem.assertions.revertWithCustomError( + pme.write.setOptions([usdc.address]), + pme, + "InvalidDependency", + ); + }); + + it("still accepts the zero address to disable options", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await pme.write.setOptions([zeroAddress]); + assert.equal(await pme.read.optionsEngine(), zeroAddress); + }); + + it("rejects a vault lacking the collateral surface", async () => { + const { pme, usdc } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await viem.assertions.revertWithCustomError( + pme.write.setVault([usdc.address]), + pme, + "InvalidDependency", + ); + }); + + it("rejects a zero vault", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await viem.assertions.revertWithCustomError( + pme.write.setVault([zeroAddress]), + pme, + "ZeroAddress", + ); + }); + + it("rejects an oracle that is not a price feed", async () => { + const { pme, usdc } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + + await viem.assertions.revertWithCustomError( + pme.write.setOracle([usdc.address]), + pme, + "InvalidDependency", + ); + }); + + it("rejects a feed that has never answered", async () => { + const { pme } = await networkHelpers.loadFixture(deployPortfolioMarginEngineFixture); + const deadFeed = await viem.deployContract("PriceOracleMock", [0n, 6]); + + await viem.assertions.revertWithCustomError( + pme.write.setOracle([deadFeed.address]), + pme, + "InvalidOracle", + ); + }); + + }); + describe("gamma and vega", () => { it("gamma reduces stress loss for long gamma position", async () => { const { pme, optionsMock, user } = await networkHelpers.loadFixture( diff --git a/docs/mango-attack.md b/docs/mango-attack.md new file mode 100644 index 0000000..977df0f --- /dev/null +++ b/docs/mango-attack.md @@ -0,0 +1,159 @@ +# Unrealized profit as collateral: the Mango attack, and why it does not work here + +This note explains the October 2022 Mango Markets exploit, extracts the general +attack it belongs to, and documents exactly which parts of this system make that +attack unprofitable. It exists because the margin engine deliberately allows +unrealized profit on one venue to offset an unrealized loss on another, and that +is precisely the design choice Mango is the cautionary tale for. The difference +between the two is narrow and load-bearing, so it is worth writing down. + +## What happened at Mango + +In October 2022 Avraham Eisenberg took roughly $110M out of Mango Markets, a +Solana perpetuals venue, and left the protocol about $47M short after partially +returning funds. The mechanism was not a bug in the usual sense. Every contract +did what it was written to do. + +He funded two accounts and used them to take large opposing MNGO-PERP positions +against himself, so the pair carried no net market risk. He then bought MNGO spot +on the three thin venues that fed Mango's price oracle. MNGO's average daily +volume that month was under $100,000, so this was cheap. The oracle price rose +more than thirteenfold in about thirty minutes. + +At that inflated mark, the long account showed an enormous unrealized profit. +Mango counted unrealized profit as collateral, so the account's borrowing power +rose with it, and he withdrew other users' real assets against it. The price then +returned to where it started. The profit that had backed the withdrawal evaporated; +the withdrawn assets did not. + +The CFTC, SEC and DOJ all brought actions. For our purposes the legal outcome +matters less than the shape of the thing. + +## The general attack + +Mango is usually filed under "oracle manipulation", which is true but not the +useful description — plenty of systems survive a manipulated oracle. The exploit +needed three conditions to hold at the same time: + +1. **A manipulable mark.** A thin instrument with few price sources. +2. **Unrealized profit increasing spendable collateral.** The inflated mark had + to translate into more borrowing or withdrawal capacity. +3. **An exit.** Real assets had to be removable while the mark was inflated. + +Break any one link and the attack stops paying. Condition 1 is a market property +and can only be managed, never eliminated — the JELLY incident on Hyperliquid in +March 2025 and the venue-local collateral marks that drove the October 2025 +liquidation cascade are both reminders that thin marks stay manipulable in both +directions. Conditions 2 and 3 are design choices, and they are where this system +differs. + +## How this system is built + +Two properties do the work. The first is the important one. + +### Unrealized profit never funds an exit + +Withdrawals are gated on initial margin, not maintenance margin: + +```solidity +// CollateralVault._checkMargin +uint256 required = IPortfolioMarginEngine(engine).computePortfolioIM(account); +if (balanceOf(account) < required) revert MarginBreach(); +``` + +and initial margin clamps unrealized PnL **per market, with gains discarded**: + +```solidity +// PortfolioMarginEngine._marginFromAggregate +uint256 pnlTokens = isIM + ? agg.unrealizedLossPerMarket + : (agg.netUnrealizedPnl < 0 ? uint256(-agg.netUnrealizedPnl) : 0); +``` + +So an unrealized gain, at any venue, on any instrument, contributes exactly zero +to the number that decides how much collateral can leave the vault. There is no +arithmetic path from an inflated mark to a larger withdrawal. Condition 3 is +absent by construction rather than by parameter choice, which means no oracle +configuration, shock setting or market listing can reintroduce it. + +The same IM figure gates opening new positions — `_ensureInitialMargin` on both +venues, and `canPlaceOrder` on the engine — so an inflated mark cannot be levered +into a larger position either. Both of the levers Eisenberg pulled read a number +that ignores his profit. + +### Profit can cancel a loss, but is never itself collateral + +Maintenance margin does net PnL across venues, and this is the part that +superficially resembles what Mango did. It is not the same operation. + +The term is `max(0, -Σ unrealizedPnl)`. A net gain contributes **zero**, not a +credit. Profit can stop a loss from being charged; it can never be charged +negatively. So the requirement can never fall below the stress term plus the +other add-ons, no matter how large the gain or how badly the mark is wrong. + +That bound is what separates the two designs. At Mango, profit was *added to* +collateral and the ceiling on extraction was the size of the lie. Here, profit +can at most decline to charge for a loss the account genuinely carries, so the +ceiling on what a manipulated mark can buy is the size of a real, offsetting +loss the attacker already holds. To benefit at all, the attacker must first be +genuinely losing money somewhere else. + +## What netting does still expose, honestly + +Maintenance margin decides liquidation, so an attacker who inflates the mark on a +venue where they hold a gain can suppress their own maintenance requirement and +postpone their liquidation. They cannot withdraw anything, cannot open anything, +and cannot touch another account. When the mark reverts they are liquidated +anyway — later, and therefore possibly deeper, which can convert a clean +liquidation into bad debt absorbed by the insurance fund. + +This is a real exposure and it is the price of the netting. Three things bound it. +The gain must sit on a genuine position, so the attacker carries real risk on the +leg they are inflating. The benefit is capped by an offsetting loss they must +actually be carrying. And the payoff is a delay rather than a transfer, which is +a far weaker incentive than $110M of withdrawable assets. + +Against that, the failure the netting *removes* is not hypothetical either. Under +a per-market clamp a delta-flat hedge across two venues becomes liquidatable as +soon as the mark moves at all, because the losing leg is charged in full while +the winning leg is invisible. That liquidates solvent accounts as a matter of +routine, on exactly the hedged flow a cross-product margin engine exists to +attract. Binance's October 2025 episode is the industry's most expensive +demonstration of what liquidating economically solvent accounts costs: over $328M +in compensation from a single venue over roughly one day. + +## Assumptions this rests on + +**Both venues must mark against consistent prices.** `Futures.priceOracle`, the +perps equivalent and `PortfolioMarginEngine`'s own feed are separately configured +storage slots. In normal deployment they point at the same hashprice feed, but +nothing in the code enforces it, and they degrade differently under staleness — +futures reverts, the engine returns zero. Netting a gain measured against a +diverged feed against a loss measured against a live one is not a real offset. +Treat oracle consistency across the three as a deployment invariant. + +**Cash is genuinely fungible between the legs.** Both venues settle into one +`CollateralVault`, in one currency, under one set of protocol rules. This is what +makes cross-venue netting an accounting identity rather than a bet on +correlation, and it is enforced: `addLinearMarket` rejects any market that pins a +different vault. If that ever stops being true, the netting argument stops +holding with it. + +## Deliberately not implemented + +The following would each tighten the residual exposure above. None is in place, +and each is a parameter decision rather than a structural one: + +- **Conservative marks on the credited side** — value gains at the worse of the + live oracle and a short TWAP, losses at the better. This is the most direct + defence against a transient manipulated print and the cheapest to add. +- **A haircut on the credited gain**, per market, so thin or far-dated + instruments offset at less than face value. +- **A liquidity floor** disabling the offset entirely below a volume or open + interest threshold. + +The reason none is urgent is the structural bound above: with no exit and no +credit beyond cancelling a real loss, these tighten a delay, not a leak. They +become materially more important if unrealized gain is ever allowed to support +withdrawal, opening, or transfer — at which point condition 3 is back and this +document is describing a system that no longer exists. diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 1f6d4f0..caec371 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -61,56 +61,6 @@ importers: specifier: ^5.8.0 version: 5.9.3 - apps/maker: - dependencies: - '@titan-mm/core': - specifier: workspace:* - version: link:../../packages/core - pino: - specifier: ^10.3.1 - version: 10.3.1 - devDependencies: - '@types/node': - specifier: ^22.0.0 - version: 22.19.17 - typescript: - specifier: ^5.8.0 - version: 5.9.3 - - packages/core: - dependencies: - ajv: - specifier: ^8.17.1 - version: 8.18.0 - ajv-formats: - specifier: ^3.0.1 - version: 3.0.1(ajv@8.18.0) - fraction.js: - specifier: ^5.2.2 - version: 5.3.4 - js-yaml: - specifier: ^4.1.0 - version: 4.1.1 - pino: - specifier: ^10.3.1 - version: 10.3.1 - viem: - specifier: ^2.45.3 - version: 2.48.4(typescript@5.9.3) - devDependencies: - '@sinclair/typebox': - specifier: ^0.34.49 - version: 0.34.49 - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 - '@types/node': - specifier: ^22.0.0 - version: 22.19.17 - typescript: - specifier: ^5.8.0 - version: 5.9.3 - packages: '@adraffy/ens-normalize@1.11.1': @@ -1686,8 +1636,8 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec} + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b} version: 1.0.0 hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: @@ -4973,7 +4923,7 @@ snapshots: '@safe-global/protocol-kit': 8.0.4(typescript@5.9.3) '@safe-global/types-kit': 4.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 - hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec + hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b 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) @@ -5117,7 +5067,7 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/addc689f9fc69201944caf1bb0066c394742c2ec: {} + hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b: {} hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: {} diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index e750048..5ad7e11 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -13,7 +13,9 @@ import type { Position, ReduceIntent, } from "../../core/adapter.ts"; +import { TimeInForce } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; +import { fillLossFromNotionals } from "../../core/math.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; import { futuresInstrumentId } from "./events.ts"; @@ -75,8 +77,11 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } async getContext(): Promise { - // Eagerly cache marginPct so `estimateOrderMargin` is synchronous. - const { marginPct } = await this.venue.getMarginInputs(); + // Eagerly cache both margin inputs so `estimateOrderMargin` is synchronous. + const [{ marginPct }] = await Promise.all([ + this.venue.getMarginInputs(), + this.venue.fetchImSpotShock(), + ]); this.marginPercentCache = marginPct; return { expirationAt: Number(this.expirationAt), @@ -89,10 +94,25 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { throw new Error(`futures: order size ${qty} must be > 0`); } const signed = intent.side === "buy" ? qty : -qty; + // Local ABI fragment until published futures-contracts carries the time-in-force arg. + const createOrderAbi = [ + { + type: "function", + name: "createOrder", + stateMutability: "nonpayable", + inputs: [ + { name: "_price", type: "uint256" }, + { name: "_expirationAt", type: "uint256" }, + { name: "_quantity", type: "int256" }, + { name: "_tif", type: "uint8" }, + ], + outputs: [], + }, + ] as const; return encodeFunctionData({ - abi: FuturesAbi, + abi: createOrderAbi, functionName: "createOrder", - args: [intent.price, this.expirationAt, signed], + args: [intent.price, this.expirationAt, signed, TimeInForce.GTC], }); } @@ -124,6 +144,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { { name: "price", type: "uint256" }, { name: "expirationAt", type: "uint256" }, { name: "quantity", type: "int256" }, + { name: "timeInForce", type: "uint8" }, ], }, ], @@ -148,6 +169,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { price: intent.price, expirationAt: intent.expirationAt ?? this.expirationAt, quantity: intent.side === "buy" ? qty : -qty, + timeInForce: TimeInForce.GTC, }; }); return encodeFunctionData({ @@ -239,14 +261,36 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** - * IM added by a new order: - * pricePerDay × |qty| × marginPct / 100 (one unit, no duration multiplier) - * Conservative (ignores the profitable-order clamp); the engine's - * `canPlaceOrder` is the real authority. + * Upper bound on the IM a new order adds, matching the two terms the engine charges: + * + * IM_added ≤ imSpotShock × mark × |qty| / 1e18 (its delta joins one stress leg) + * + max(0, |qty| × (limit − mark)) (bid) or + * max(0, |qty| × (mark − limit)) (ask) + * + * One contract is one unit of delta at `pricePerDay` — no duration multiplier — so + * the arithmetic is the perps formula with a quantity scale of 1. + * + * This used to be `pricePerDay × |qty| × liquidationMarginPercent / 100`. That + * coefficient is not what the engine applies: it stresses futures delta with the + * portfolio-wide `imSpotShock` alongside every other market's, and it charges the + * order's instant fill loss separately. A bound rather than the exact figure for the + * same reason as perps — the engine takes the worse of two netted legs, so an order + * that moves the portfolio toward flat can be free, and charging it in full can only + * over-estimate. */ estimateOrderMargin(intent: OrderIntent): bigint { - if (this.marginPercentCache === null) return 0n; - return (intent.price * intent.size * this.marginPercentCache) / 100n; + const shock = this.venue.cachedImSpotShock(); + if (shock === null) return 0n; + const mark = this.venue.cachedMarketPrice(); + if (mark === null) return 0n; + + const stress = (mark * intent.size * shock) / 10n ** 18n; + const fillLoss = fillLossFromNotionals( + intent.price * intent.size, + mark * intent.size, + intent.side, + ); + return stress + fillLoss; } async estimateCreateGas(account: `0x${string}`): Promise { diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index fdd4d8c..faea3c3 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -95,6 +95,7 @@ export class FuturesVenueAdapter implements VenueAdapter { private engineAddressCache: `0x${string}` | null = null; private collateralTokenCache: `0x${string}` | null = null; private marginPercentCache: bigint | null = null; + private imSpotShockCache: bigint | null = null; private readonly rawOracle: RawOracleReader; constructor(opts: FuturesVenueOptions) { @@ -345,9 +346,23 @@ export class FuturesVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** + * The mark from the most recent `getRawMarketPrice()`, or `null` before the first + * read. Lets the synchronous `estimateOrderMargin` charge an order's instant fill + * loss against the same mark the quotes were built from. + */ + cachedMarketPrice(): bigint | null { + return this.rawOracle.lastPrice(); + } + /** * Cache marginPercent on the venue. It is static-ish (admin-changeable) so we - * read it once and reuse for the `estimateOrderMargin` formula. + * read it once and reuse it. + * + * No longer feeds `estimateOrderMargin`: the engine stresses a futures contract's + * delta with the portfolio-wide `imSpotShock`, not the venue's own + * `liquidationMarginPercent`. Kept because the liquidation-margin figure is still + * the right thing to report and reason about for positions. */ async getMarginInputs(): Promise<{ marginPct: bigint }> { if (this.marginPercentCache !== null) { @@ -358,12 +373,26 @@ export class FuturesVenueAdapter implements VenueAdapter { abi: FuturesAbi, functionName: "liquidationMarginPercent", }); - // Note: `getMarginPercent` on chain adds a breach-penalty term we don't - // mirror here — we use `liquidationMarginPercent` as a slight over-estimate. - // The on-chain check is the real authority; this is just our pre-trade gate. this.marginPercentCache = BigInt(liqMarginPct); return { marginPct: this.marginPercentCache }; } + + async fetchImSpotShock(): Promise { + if (this.imSpotShockCache !== null) return this.imSpotShockCache; + const { engine } = await this.resolveAddresses(); + const shock = await this.publicClient.readContract({ + address: engine, + abi: PortfolioMarginEngineAbi, + functionName: "imSpotShock", + }); + this.imSpotShockCache = shock; + return shock; + } + + /** The cached IM spot shock, or `null` before the first fetch. */ + cachedImSpotShock(): bigint | null { + return this.imSpotShockCache; + } } /** @@ -383,7 +412,8 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads. * `shared` order matches the perps account so the aggregator can decode one * shared result slice for every venue: - * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance, + * portfolioOrderMargin] */ async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; @@ -396,10 +426,10 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "orderMarginOf", args: [owner] }, ] as MarginReadPlan["shared"]; const venue = [ - { address: this.venue.address, abi: FuturesAbi, functionName: "getOrderMargin", args: [owner] }, { address: this.venue.address, abi: FuturesAbi, functionName: "getUnrealizedPnl", args: [owner] }, ] as MarginReadPlan["venue"]; @@ -410,7 +440,7 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { vaultBalance, portfolioIM, portfolioMM, - venueOrderMargin: r[5], + portfolioOrderMargin: r[5], venueUnrealizedPnl: r[6], walletTokenBalance, nativeBalance, @@ -430,11 +460,11 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { return plan.decode(results); } - async imSpotShock(): Promise { - // Futures uses pricePerDay × marginPct/100 (one unit, no duration multiplier), - // not a spot-shock model. Returns 0 to signal "not applicable" — adapters don't - // use this directly; estimateOrderMargin reads from getMarginInputs instead. - return 0n; + imSpotShock(): Promise { + // The engine treats a futures contract as one unit of delta and stresses it with + // the same portfolio-wide shock it applies to perps, so this is no longer "not + // applicable" — it is the coefficient `estimateOrderMargin` needs. + return this.venue.fetchImSpotShock(); } async deposit(amount: bigint): Promise { diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 044570d..de018fe 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -17,8 +17,9 @@ import type { ReduceIntent, Unsubscribe, } from "../../core/adapter.ts"; +import { TimeInForce } from "../../core/adapter.ts"; import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.ts"; -import { calculateNotional } from "../../core/math.ts"; +import { calculateNotional, fillLossFromNotionals } from "../../core/math.ts"; import type { PerpsVenueAdapter } from "./venue.ts"; const PERPS_INSTRUMENT_ID = "perps"; @@ -69,10 +70,24 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { encodeCreate(intent: OrderIntent): `0x${string}` { // Perps' createOrder takes a SIGNED quantity (positive = buy, negative = sell). const signed = intent.side === "buy" ? intent.size : -intent.size; + // Local ABI fragment until published perps-contracts carries the time-in-force arg. + const createOrderAbi = [ + { + type: "function", + name: "createOrder", + stateMutability: "nonpayable", + inputs: [ + { name: "_price", type: "uint256" }, + { name: "_quantity", type: "int256" }, + { name: "_tif", type: "uint8" }, + ], + outputs: [], + }, + ] as const; return encodeFunctionData({ - abi: HashPowerPerpsDEXAbi, + abi: createOrderAbi, functionName: "createOrder", - args: [intent.price, signed], + args: [intent.price, signed, TimeInForce.GTC], }); } @@ -103,6 +118,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { components: [ { name: "price", type: "uint256" }, { name: "quantity", type: "int256" }, + { name: "timeInForce", type: "uint8" }, ], }, ], @@ -121,6 +137,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { const batch = creates.map((intent) => ({ price: intent.price, quantity: intent.side === "buy" ? intent.size : -intent.size, + timeInForce: TimeInForce.GTC, })); return encodeFunctionData({ abi: updateOrdersAbi, @@ -205,15 +222,27 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { } /** - * Mirrors `HashPowerPerpsDEX._getMargin` for a single new resting order: - * IM_added = imSpotShock × notional / 1e18 + * Upper bound on the IM a single new resting order adds, matching the two terms + * `PortfolioMarginEngine` charges for it: * - * The on-chain formula reduces this by any "risk-reducing" overlap with - * an existing position, but the MM is conservative on the high side here: - * we estimate ignoring the reducer (worst-case more IM, never less), - * so the engine.canPlaceOrder gate has slack rather than slop. + * IM_added ≤ imSpotShock × mark × size / 1e18 (its delta joins one stress leg) + * + max(0, size × (limit − mark)) (bid) or + * max(0, size × (mark − limit)) (ask) * - * Returns 0n if `imSpotShock` hasn't been cached yet — caller treats + * A bound rather than the exact figure, deliberately, and for a reason that is now + * structural rather than a convenience: the engine takes the *worse* of the + * `netDelta + buyOrderDelta` and `netDelta − sellOrderDelta` legs, so a single + * order's true marginal cost depends on the whole portfolio's net delta and can be + * zero when the order moves the account toward flat. Charging it the full stress on + * its own delta can only over-estimate: adding buy delta cannot raise the sell leg, + * and vice versa. The `engine.canPlaceOrder` gate therefore has slack, not slop. + * + * The mark price matters here and did not before. The old estimate used the order's + * *limit* price against the shock and nothing else, which under-charged both an + * aggressive bid (whose fill loss is the dominant term) and a deep one (whose stress + * is set by the mark, not the limit). + * + * Returns 0n if `imSpotShock` or the mark haven't been cached yet — caller treats * "0 additional" as "no information; proceed", which is fine on first * tick because the engine itself enforces the floor. */ @@ -224,8 +253,16 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { const cached = (this.venue as unknown as { imSpotShockCache?: bigint }) .imSpotShockCache; if (!cached) return 0n; - const notional = calculateNotional(intent.price, intent.size); - return (notional * cached) / 10n ** 18n; + const mark = this.venue.cachedMarketPrice(); + if (mark === null) return 0n; + + const stress = (calculateNotional(mark, intent.size) * cached) / 10n ** 18n; + const fillLoss = fillLossFromNotionals( + calculateNotional(intent.price, intent.size), + calculateNotional(mark, intent.size), + intent.side, + ); + return stress + fillLoss; } async estimateCreateGas(account: `0x${string}`): Promise { diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 8df2079..2c00cf1 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -258,6 +258,15 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** + * The mark from the most recent `getRawMarketPrice()`, or `null` before the first + * read. Lets the synchronous `estimateOrderMargin` charge an order's instant fill + * loss against the same mark the quotes were built from. + */ + cachedMarketPrice(): bigint | null { + return this.rawOracle.lastPrice(); + } + /** * Assert the compiled `QUANTITY_DECIMALS` matches the on-chain * `HashPowerPerpsDEX.QUANTITY_DECIMALS()`. The off-chain sizing/notional math @@ -311,7 +320,13 @@ class PerpsCollateralAccount implements BatchableCollateralAccount { * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads * so the portfolio aggregator can batch every venue into one multicall. * `shared` order is canonical across venues: - * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance, + * portfolioOrderMargin] + * + * Order margin is a shared read rather than a venue read: the engine nets every + * venue's per-side order delta into one portfolio net delta before stressing it, so + * asking each venue for its own slice and adding them up would double-count the + * stress and ignore the netting. */ async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; @@ -324,10 +339,10 @@ class PerpsCollateralAccount implements BatchableCollateralAccount { { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "orderMarginOf", args: [owner] }, ] as MarginReadPlan["shared"]; const venue = [ - { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getOrderMargin", args: [owner] }, { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, ] as MarginReadPlan["venue"]; @@ -335,7 +350,7 @@ class PerpsCollateralAccount implements BatchableCollateralAccount { const decode = (results: readonly unknown[]): CollateralSnapshot => { const r = results as bigint[]; const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; - const orderMargin = r[5]; + const portfolioOrderMargin = r[5]; const perpsUnrealizedPnl = r[6]; const pendingFunding = r[7]; // Funding owed (positive) reduces effective unrealized PnL. @@ -343,7 +358,7 @@ class PerpsCollateralAccount implements BatchableCollateralAccount { vaultBalance, portfolioIM, portfolioMM, - venueOrderMargin: orderMargin, + portfolioOrderMargin, venueUnrealizedPnl: perpsUnrealizedPnl - pendingFunding, walletTokenBalance, nativeBalance, diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index dc6b4d7..70f7c27 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -16,6 +16,16 @@ import type { */ export type Side = "buy" | "sell"; +/** + * Mirrors the venues' on-chain `TimeInForce` enum. Every placement carries one; + * the maker only ever rests liquidity, so it always quotes GTC. + */ +export const TimeInForce = { + GTC: 0, + IOC: 1, + FOK: 2, +} as const; + /** * A single new-order intent. `size` is unsigned and in the **venue's native * unit** (e.g. perps uses QUANTITY_SCALE bigints, futures uses int8 contract @@ -128,7 +138,13 @@ export interface CollateralSnapshot { vaultBalance: bigint; portfolioIM: bigint; portfolioMM: bigint; - venueOrderMargin: bigint; + /** + * `PortfolioMarginEngine.orderMarginOf(owner)` — the IM the account's resting + * orders add on top of its positions, across every registered market. Portfolio-wide + * by construction: the engine nets each venue's per-side order delta into portfolio + * net delta before stressing, so there is no per-venue figure left to sum. + */ + portfolioOrderMargin: bigint; venueUnrealizedPnl: bigint; walletTokenBalance: bigint; nativeBalance: bigint; @@ -341,12 +357,19 @@ export interface InstrumentAdapter { * wallet's portfolio IM. Used by RiskManager to call * `engine.canPlaceOrder(wallet, sumAdditionalIM)` before placing. * - * Mirrors the on-chain margin computation for the venue: - * - Perps: imSpotShock × notional / 1e18 - * - Futures: pricePerDay × marginPct / 100 (one unit, no duration multiplier) + * An upper bound on the engine's two order terms, identical in shape for both + * venues now that the engine treats every market's order delta the same way: + * + * imSpotShock × mark × |size| / 1e18 + instant fill loss vs. the mark + * + * A bound rather than the exact figure because the engine stresses the *worse* of + * `netDelta + buyOrderDelta` and `netDelta − sellOrderDelta`, so an order's true + * marginal cost depends on the whole portfolio and is zero when the order only moves + * the account toward flat. Charging its own delta in full can only over-estimate, + * which leaves the `canPlaceOrder` gate with slack rather than slop. * - * Adapter computes synchronously from already-cached state (imSpotShock, - * marginPct). Returns 0n if it can't be estimated yet. + * Adapter computes synchronously from already-cached state (imSpotShock and the last + * mark). Returns 0n if it can't be estimated yet. */ estimateOrderMargin(intent: OrderIntent): bigint; diff --git a/market-maker/src/core/collateralTracker.ts b/market-maker/src/core/collateralTracker.ts index b5bc227..fe86848 100644 --- a/market-maker/src/core/collateralTracker.ts +++ b/market-maker/src/core/collateralTracker.ts @@ -33,7 +33,7 @@ export class CollateralTracker { vaultBalance = 0n; portfolioIM = 0n; portfolioMM = 0n; - venueOrderMargin = 0n; + portfolioOrderMargin = 0n; venueUnrealizedPnl = 0n; walletTokenBalance = 0n; nativeBalance = 0n; @@ -120,7 +120,7 @@ export class CollateralTracker { this.vaultBalance = s.vaultBalance; this.portfolioIM = s.portfolioIM; this.portfolioMM = s.portfolioMM; - this.venueOrderMargin = s.venueOrderMargin; + this.portfolioOrderMargin = s.portfolioOrderMargin; this.venueUnrealizedPnl = s.venueUnrealizedPnl; this.walletTokenBalance = s.walletTokenBalance; this.nativeBalance = s.nativeBalance; diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index db62ea1..99c422e 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -149,7 +149,7 @@ export class HealthCheck { vaultUsdc: formatUsdcAmount(collateral.vaultBalance), portfolioImUsdc: formatUsdcAmount(collateral.portfolioIM), portfolioMmUsdc: formatUsdcAmount(collateral.portfolioMM), - venueOrderMarginUsdc: formatUsdcAmount(collateral.venueOrderMargin), + portfolioOrderMarginUsdc: formatUsdcAmount(collateral.portfolioOrderMargin), venueUnrealizedPnlUsdc: formatUsdcAmount(collateral.venueUnrealizedPnl), ethBalance: formatEthAmount(collateral.nativeBalance), utilization: `${collateral.utilizationPct}%`, @@ -202,7 +202,7 @@ export class HealthCheck { vaultBalance: collateral.vaultBalance.toString(), portfolioIM: collateral.portfolioIM.toString(), portfolioMM: collateral.portfolioMM.toString(), - venueOrderMargin: collateral.venueOrderMargin.toString(), + portfolioOrderMargin: collateral.portfolioOrderMargin.toString(), venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), walletTokenBalance: collateral.walletTokenBalance.toString(), nativeBalance: collateral.nativeBalance.toString(), diff --git a/market-maker/src/core/math.ts b/market-maker/src/core/math.ts index 9d8049c..26ec540 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -40,6 +40,22 @@ export function calculateNotional(price: bigint, absQuantity: bigint): bigint { return (price * q) / QUANTITY_SCALE; } +/** + * Mark-to-market loss the account eats the instant a resting order fills, which the + * engine charges on top of the stress term. A bid pays its limit for something worth + * the mark; an ask sells at its limit something worth the mark. Only the losing + * direction counts — the venues clamp each side at zero rather than letting a + * favourably-priced order fund an unfavourable one. + * + * `notional` is the caller's own quantity convention (perps scale by `QUANTITY_SCALE`, + * futures pass whole contracts), so both venues can share this by supplying their own + * notional function. + */ +export function fillLossFromNotionals(limitNotional: bigint, markNotional: bigint, side: "buy" | "sell"): bigint { + const loss = side === "buy" ? limitNotional - markNotional : markNotional - limitNotional; + return loss > 0n ? loss : 0n; +} + /** * Convert a USD notional amount to venue-native size at `price`, rounded to * the nearest native unit (half-up). diff --git a/market-maker/src/core/portfolioCollateral.ts b/market-maker/src/core/portfolioCollateral.ts index b5daf39..b0eae1b 100644 --- a/market-maker/src/core/portfolioCollateral.ts +++ b/market-maker/src/core/portfolioCollateral.ts @@ -52,7 +52,6 @@ export class PortfolioCollateralAccount implements CollateralAccount { const sharedResults = results.slice(0, shared.length); let offset = shared.length; - let venueOrderMargin = 0n; let venueUnrealizedPnl = 0n; let primary: CollateralSnapshot | null = null; @@ -61,24 +60,23 @@ export class PortfolioCollateralAccount implements CollateralAccount { offset += plan.venue.length; const snap = plan.decode([...sharedResults, ...venueResults]); if (!primary) primary = snap; - venueOrderMargin += snap.venueOrderMargin; venueUnrealizedPnl += snap.venueUnrealizedPnl; } - return { ...(primary as CollateralSnapshot), venueOrderMargin, venueUnrealizedPnl }; + // `portfolioOrderMargin` comes from a shared read, so it is already the whole + // portfolio's figure and must be taken once rather than summed per venue. + return { ...(primary as CollateralSnapshot), venueUnrealizedPnl }; } /** Fallback: one snapshot RPC per account. */ private async perAccountSnapshot(): Promise { const snaps = await Promise.all(this.accounts.map((a) => a.snapshot())); const primary = snaps[0]; - let venueOrderMargin = 0n; let venueUnrealizedPnl = 0n; for (const s of snaps) { - venueOrderMargin += s.venueOrderMargin; venueUnrealizedPnl += s.venueUnrealizedPnl; } - return { ...primary, venueOrderMargin, venueUnrealizedPnl }; + return { ...primary, venueUnrealizedPnl }; } imSpotShock(): Promise { diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts index 56e00fc..dc38414 100644 --- a/market-maker/src/core/portfolioHealth.ts +++ b/market-maker/src/core/portfolioHealth.ts @@ -115,7 +115,7 @@ export class PortfolioHealthCheck { vaultUsdc: formatUsdcAmount(collateral.vaultBalance), portfolioImUsdc: formatUsdcAmount(collateral.portfolioIM), portfolioMmUsdc: formatUsdcAmount(collateral.portfolioMM), - venueOrderMarginUsdc: formatUsdcAmount(collateral.venueOrderMargin), + portfolioOrderMarginUsdc: formatUsdcAmount(collateral.portfolioOrderMargin), venueUnrealizedPnlUsdc: formatUsdcAmount(collateral.venueUnrealizedPnl), ethBalance: formatEthAmount(collateral.nativeBalance), utilization: `${collateral.utilizationPct}%`, @@ -168,7 +168,7 @@ export class PortfolioHealthCheck { vaultBalance: collateral.vaultBalance.toString(), portfolioIM: collateral.portfolioIM.toString(), portfolioMM: collateral.portfolioMM.toString(), - venueOrderMargin: collateral.venueOrderMargin.toString(), + portfolioOrderMargin: collateral.portfolioOrderMargin.toString(), venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), walletTokenBalance: collateral.walletTokenBalance.toString(), nativeBalance: collateral.nativeBalance.toString(), diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts index d345b9f..c2cc940 100644 --- a/market-maker/src/core/rawOracle.ts +++ b/market-maker/src/core/rawOracle.ts @@ -56,6 +56,7 @@ export class RawOracleReader { private readonly resolve: () => Promise; private readonly label: string; private cache: RawOracleConfig | null = null; + private lastAnswer: bigint | null = null; constructor(opts: { publicClient: PublicClient; @@ -84,7 +85,20 @@ export class RawOracleReader { throw new Error(`${this.label}: oracle returned non-positive answer (${answer.toString()})`); } // Mirror the venue's `getMarketPrice()` decimal rebase (no unit factor). - return answer / this.cache.divisor; + this.lastAnswer = answer / this.cache.divisor; + return this.lastAnswer; + } + + /** + * The most recent price `read()` returned, or `null` before the first read. + * + * Exists so the synchronous `estimateOrderMargin` can price an order's fill loss + * against the mark. Every quoting tick calls `getIndexPrice()` (hence `read()`) + * before it decides what to place, so this is the same mark the quotes were built + * from — using it keeps the pre-trade IM estimate consistent with them. + */ + lastPrice(): bigint | null { + return this.lastAnswer; } /** Drop cached (oracle, divisor) — next `read()` will re-resolve. */ diff --git a/market-maker/tests-pending/market-maker.e2e.test.ts b/market-maker/tests-pending/market-maker.e2e.test.ts index 654338b..685d670 100644 --- a/market-maker/tests-pending/market-maker.e2e.test.ts +++ b/market-maker/tests-pending/market-maker.e2e.test.ts @@ -16,6 +16,7 @@ import { hashPowerPerpsDexAbi, priceOracleMockAbi } from "../src/abi.ts"; import { hardhat } from "../src/client.ts"; import { startHardhatNode, createMakerConfig, loadFixture, type HardhatNode } from "./helpers.ts"; import { deployWithCollateralFixture } from "../../contracts/fixtures/viem.ts"; +import { TimeInForce } from "../src/core/adapter.ts"; const silentLogger = pino({ level: "silent" }); @@ -239,9 +240,9 @@ describe("MM fill handling", () => { await ( perps as unknown as { - write: { createOrder: (args: [bigint, bigint], opts: unknown) => Promise }; + write: { createOrder: (args: [bigint, bigint, number], opts: unknown) => Promise }; } - ).write.createOrder([bestAsk.price, takerQty], { + ).write.createOrder([bestAsk.price, takerQty, TimeInForce.GTC], { account: deployment.clients.buyerWallet.account, }); @@ -259,9 +260,9 @@ describe("MM fill handling", () => { const takerQty = parseUnits("1", deployment.config.quantityDecimals); await ( perps as unknown as { - write: { createOrder: (args: [bigint, bigint], opts: unknown) => Promise }; + write: { createOrder: (args: [bigint, bigint, number], opts: unknown) => Promise }; } - ).write.createOrder([bestAsk.price, takerQty], { + ).write.createOrder([bestAsk.price, takerQty, TimeInForce.GTC], { account: deployment.clients.buyerWallet.account, }); diff --git a/market-maker/tests-pending/market-maker.process.test.ts b/market-maker/tests-pending/market-maker.process.test.ts index 9297fb8..7e3528a 100644 --- a/market-maker/tests-pending/market-maker.process.test.ts +++ b/market-maker/tests-pending/market-maker.process.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import { getContract, parseUnits, type Hex } from "viem"; import { hashPowerPerpsDexAbi, priceOracleMockAbi } from "../src/abi.ts"; +import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { startHardhatNode, waitFor, @@ -16,6 +17,7 @@ import { type HardhatNode, } from "./helpers.ts"; import { deployWithCollateralFixture } from "../../contracts/fixtures/viem.ts"; +import { TimeInForce } from "../src/core/adapter.ts"; const MM_ACCOUNT = HARDHAT_ACCOUNTS[3]; const TAKER_ACCOUNT = HARDHAT_ACCOUNTS[2]; @@ -189,7 +191,11 @@ describe("MM process — quoting and fills", () => { client: { public: publicClient, wallet: takerWallet }, }); - await perps.write.createOrder([bestAsk, parseUnits("1", deployment.config.quantityDecimals)]); + await perps.write.createOrder([ + bestAsk, + parseUnits("1", deployment.config.quantityDecimals), + TimeInForce.GTC, + ]); // Wait for MM to detect the fill let hAfter!: Record; @@ -499,7 +505,7 @@ describe("MM process — post-fill on-chain state", () => { abi: hashPowerPerpsDexAbi, client: { public: publicClient, wallet: takerWallet }, }); - await perps.write.createOrder([bestAsk, qty]); + await perps.write.createOrder([bestAsk, qty, TimeInForce.GTC]); const pos = (await publicClient.readContract({ address: deployment.contracts.perpsAddress, @@ -514,9 +520,9 @@ describe("MM process — post-fill on-chain state", () => { it("should have non-zero required margin after position opens", async () => { const reqMargin = (await publicClient.readContract({ - address: deployment.contracts.perpsAddress, - abi: hashPowerPerpsDexAbi, - functionName: "getMaintenanceMargin", + address: deployment.contracts.pmeAddress, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioMM", args: [MM_ACCOUNT.address], })) as bigint; @@ -592,7 +598,7 @@ describe("MM process — post-fill on-chain state", () => { client: { public: publicClient, wallet: takerWallet }, }); // Taker sells into MM's bid - await perps.write.createOrder([bestBid, -qty]); + await perps.write.createOrder([bestBid, -qty, TimeInForce.GTC]); const posAfter = (await publicClient.readContract({ address: deployment.contracts.perpsAddress, diff --git a/market-maker/tests/core/collateralTracker.test.ts b/market-maker/tests/core/collateralTracker.test.ts index 07b3bc2..22f193d 100644 --- a/market-maker/tests/core/collateralTracker.test.ts +++ b/market-maker/tests/core/collateralTracker.test.ts @@ -15,7 +15,7 @@ function makeAccount(initial: Partial): { vaultBalance: 0n, portfolioIM: 0n, portfolioMM: 0n, - venueOrderMargin: 0n, + portfolioOrderMargin: 0n, venueUnrealizedPnl: 0n, walletTokenBalance: 0n, nativeBalance: 0n, diff --git a/market-maker/tests/core/math.test.ts b/market-maker/tests/core/math.test.ts index 4ccf27a..ae88711 100644 --- a/market-maker/tests/core/math.test.ts +++ b/market-maker/tests/core/math.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + fillLossFromNotionals, BPS_SCALE, RollingBudget, RollingWindow, @@ -222,3 +223,25 @@ describe("bigAbs", () => { assert.equal(bigAbs(0n), 0n); }); }); + +describe("fillLossFromNotionals", () => { + it("charges a bid that pays above the mark", () => { + // 1 contract bid at $101 with the mark at $100 — fills $1 in the red. + assert.equal(fillLossFromNotionals(101_000_000n, 100_000_000n, "buy"), 1_000_000n); + }); + + it("charges an ask that sells below the mark", () => { + assert.equal(fillLossFromNotionals(99_000_000n, 100_000_000n, "sell"), 1_000_000n); + }); + + it("clamps a favourably-priced order to zero rather than crediting it", () => { + // Both venues clamp per side, so a bid below the mark cannot fund an ask above it. + assert.equal(fillLossFromNotionals(99_000_000n, 100_000_000n, "buy"), 0n); + assert.equal(fillLossFromNotionals(101_000_000n, 100_000_000n, "sell"), 0n); + }); + + it("is zero at the mark on both sides", () => { + assert.equal(fillLossFromNotionals(100_000_000n, 100_000_000n, "buy"), 0n); + assert.equal(fillLossFromNotionals(100_000_000n, 100_000_000n, "sell"), 0n); + }); +}); diff --git a/market-maker/tests/core/portfolioCollateral.test.ts b/market-maker/tests/core/portfolioCollateral.test.ts index e570f5e..a063a07 100644 --- a/market-maker/tests/core/portfolioCollateral.test.ts +++ b/market-maker/tests/core/portfolioCollateral.test.ts @@ -30,9 +30,10 @@ function reads(values: bigint[]): MarginReadPlan["shared"] { })) as unknown as MarginReadPlan["shared"]; } -const SHARED = [100n, 200n, 300n, 400n, 500n]; // vault, IM, MM, wallet, native +// vault, IM, MM, wallet, native, portfolio order margin +const SHARED = [100n, 200n, 300n, 400n, 500n, 600n]; -function decodeShared(results: readonly unknown[]): Omit { +function decodeShared(results: readonly unknown[]): Omit { const r = results as bigint[]; return { vaultBalance: r[0], @@ -40,21 +41,17 @@ function decodeShared(results: readonly unknown[]): Omit => ({ shared: reads(sharedValues), - venue: reads([orderMargin, pnl]), + venue: reads([pnl]), decode: (results) => ({ ...decodeShared(results), - venueOrderMargin: (results as bigint[])[5], venueUnrealizedPnl: (results as bigint[])[6], }), }); @@ -62,7 +59,7 @@ function makeBatchable( buildMarginReadPlan, snapshot: async () => { const plan = await buildMarginReadPlan(); - return plan.decode([...sharedValues, orderMargin, pnl]); + return plan.decode([...sharedValues, pnl]); }, imSpotShock: async () => 0n, deposit: async () => {}, @@ -73,9 +70,9 @@ function makeBatchable( describe("PortfolioCollateralAccount", () => { it("batches all venues into one multicall, reading shared state once", async () => { const spy = makeMulticallSpy(); - const a = makeBatchable(SHARED, 11n, 22n); + const a = makeBatchable(SHARED, 22n); // b's shared values are ignored (aggregator reads shared from the first plan). - const b = makeBatchable([9n, 9n, 9n, 9n, 9n], 33n, 44n); + const b = makeBatchable([9n, 9n, 9n, 9n, 9n, 9n], 44n); const acct = new PortfolioCollateralAccount([a, b], spy.publicClient); const snap = await acct.snapshot(); @@ -83,7 +80,9 @@ describe("PortfolioCollateralAccount", () => { assert.equal(spy.state.calls, 1); // single RPC round trip assert.equal(snap.vaultBalance, 100n); // shared from first plan assert.equal(snap.portfolioIM, 200n); - assert.equal(snap.venueOrderMargin, 44n); // 11 + 33 + // Order margin is a shared read of the engine's portfolio-wide figure, so it is + // taken once and not summed across venues the way per-venue PnL is. + assert.equal(snap.portfolioOrderMargin, 600n); assert.equal(snap.venueUnrealizedPnl, 66n); // 22 + 44 }); @@ -94,7 +93,7 @@ describe("PortfolioCollateralAccount", () => { vaultBalance: 1_000n, portfolioIM: 50n, portfolioMM: 25n, - venueOrderMargin: 7n, + portfolioOrderMargin: 7n, venueUnrealizedPnl: -3n, walletTokenBalance: 0n, nativeBalance: 0n, @@ -104,13 +103,13 @@ describe("PortfolioCollateralAccount", () => { deposit: async () => {}, canPlace: async () => true, }; - const batchable = makeBatchable(SHARED, 5n, 5n); + const batchable = makeBatchable(SHARED, 5n); const acct = new PortfolioCollateralAccount([legacy, batchable], spy.publicClient); const snap = await acct.snapshot(); assert.equal(spy.state.calls, 0); // no batched multicall; each account snapshots itself assert.equal(snap.vaultBalance, 1_000n); // primary = first (legacy) - assert.equal(snap.venueOrderMargin, 12n); // 7 + 5 + assert.equal(snap.portfolioOrderMargin, 7n); // from the primary, not summed assert.equal(snap.venueUnrealizedPnl, 2n); // -3 + 5 }); @@ -126,7 +125,7 @@ describe("PortfolioCollateralAccount", () => { const spy = makeMulticallSpy(); const calls: string[] = []; const primary: CollateralAccount = { - snapshot: async () => makeBatchable(SHARED, 0n, 0n).snapshot(), + snapshot: async () => makeBatchable(SHARED, 0n).snapshot(), imSpotShock: async () => { calls.push("shock"); return 42n; @@ -139,7 +138,7 @@ describe("PortfolioCollateralAccount", () => { return im < 100n; }, }; - const secondary = makeBatchable(SHARED, 1n, 1n); + const secondary = makeBatchable(SHARED, 1n); const acct = new PortfolioCollateralAccount([primary, secondary], spy.publicClient); assert.equal(await acct.imSpotShock(), 42n); diff --git a/keeper/tests/predict/mm.test.ts b/portfolio-margin/tests/mm.test.ts similarity index 100% rename from keeper/tests/predict/mm.test.ts rename to portfolio-margin/tests/mm.test.ts diff --git a/keeper/tests/predict/solve.test.ts b/portfolio-margin/tests/solve.test.ts similarity index 100% rename from keeper/tests/predict/solve.test.ts rename to portfolio-margin/tests/solve.test.ts diff --git a/keeper/tests/predict/solveTarget.test.ts b/portfolio-margin/tests/solveTarget.test.ts similarity index 100% rename from keeper/tests/predict/solveTarget.test.ts rename to portfolio-margin/tests/solveTarget.test.ts From cb9b4f6f810da1db58babab5dcadfaf5d8b65f77 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 5 Aug 2026 17:17:28 +0200 Subject: [PATCH 129/180] refactor(keeper): extract the margin model into @hashpower/portfolio-margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keeper and the trading UI both have to answer "at what price does this account become liquidatable?", and they have to answer it the same way. While each kept its own copy of the math they drifted — they clamped unrealized PnL differently, so they disagreed about who was liquidatable. One implementation makes that class of bug impossible. Ships TypeScript sources with no build step, consumed over a git URL the way the ABI packages already are, so nothing has to be published or kept in sync and the keeper's Docker context stays as it is. The math tests move with the math, keeping the library verifiable on its own. Co-authored-by: Cursor --- keeper/package.json | 1 + keeper/pnpm-lock.yaml | 7 +- keeper/src/coordinator/planner.ts | 18 + keeper/src/predict/coordinator.ts | 7 +- keeper/src/predict/mm.ts | 155 ---- keeper/src/predict/predictiveIndex.ts | 2 +- keeper/src/predict/snapshot.ts | 95 ++- keeper/src/predict/solve.ts | 508 ----------- keeper/src/predict/types.ts | 99 --- keeper/src/venues/futures.ts | 3 +- keeper/src/venues/perps.ts | 3 +- keeper/src/venues/types.ts | 3 +- keeper/tests/integration/deployStack.ts | 72 +- keeper/tests/integration/scenarios.ts | 17 +- keeper/tests/predict/coordinator.test.ts | 19 +- .../tests/predict/coordinatorAlerts.test.ts | 19 +- keeper/tests/predict/snapshot.test.ts | 91 +- keeper/tests/venues/reduceToTarget.test.ts | 61 +- portfolio-margin/README.md | 61 ++ portfolio-margin/package.json | 35 + portfolio-margin/pnpm-lock.yaml | 128 +++ portfolio-margin/src/index.ts | 52 ++ portfolio-margin/src/mm.ts | 284 +++++++ portfolio-margin/src/solve.ts | 793 ++++++++++++++++++ portfolio-margin/src/types.ts | 153 ++++ portfolio-margin/tests/mm.test.ts | 579 +++++++++++-- portfolio-margin/tests/solve.test.ts | 474 ++++++++++- portfolio-margin/tests/solveTarget.test.ts | 253 +++++- portfolio-margin/tsconfig.json | 16 + 29 files changed, 3036 insertions(+), 972 deletions(-) delete mode 100644 keeper/src/predict/mm.ts delete mode 100644 keeper/src/predict/solve.ts delete mode 100644 keeper/src/predict/types.ts create mode 100644 portfolio-margin/README.md create mode 100644 portfolio-margin/package.json create mode 100644 portfolio-margin/pnpm-lock.yaml create mode 100644 portfolio-margin/src/index.ts create mode 100644 portfolio-margin/src/mm.ts create mode 100644 portfolio-margin/src/solve.ts create mode 100644 portfolio-margin/src/types.ts create mode 100644 portfolio-margin/tsconfig.json diff --git a/keeper/package.json b/keeper/package.json index 3cf6f70..30c1301 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,6 +21,7 @@ "lint:fix": "biome check --write ." }, "dependencies": { + "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#feat/hashprice-usd-1ph-unit&path:/portfolio-margin", "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts/abi", diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 44f2526..6ae0888 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@hashpower/portfolio-margin': + specifier: link:../portfolio-margin + version: link:../portfolio-margin amaro: specifier: ^1.1.9 version: 1.1.10 @@ -210,7 +213,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-S71kDrzEuZGW+WlkvdKVp/SHe0f5bpA1HnSJnl2ymC1lcr+T07njTa+ZhG5V49qwyOJlCTADED0zvar8E6o+Ew==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} + resolution: {gitHosted: true, integrity: sha512-S71kDrzEuZGW+WlkvdKVp/SHe0f5bpA1HnSJnl2ymC1lcr+T07njTa+ZhG5V49qwyOJlCTADED0zvar8E6o+Ew==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} version: 0.0.0 end-of-stream@1.4.5: @@ -226,7 +229,7 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, integrity: sha512-DH1KCNN4FM6f+Et4yGxiHfv+GXKShVNxvBmGPvSWE5y/D046FDtzCdcs9ptaeCTgjWPKiHcB6BmB6wp2NGp+HQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} + resolution: {gitHosted: true, integrity: sha512-DH1KCNN4FM6f+Et4yGxiHfv+GXKShVNxvBmGPvSWE5y/D046FDtzCdcs9ptaeCTgjWPKiHcB6BmB6wp2NGp+HQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} version: 0.0.0 help-me@5.0.0: diff --git a/keeper/src/coordinator/planner.ts b/keeper/src/coordinator/planner.ts index 2ab64c3..ed8f487 100644 --- a/keeper/src/coordinator/planner.ts +++ b/keeper/src/coordinator/planner.ts @@ -39,6 +39,13 @@ interface StepReport { * 2. If `mmSurplus >= 0`: account is healthy — emit `done`. * 3. Else: call `liquidateOrders` on every venue that has open orders. * Re-snapshot health. + * + * EVERY venue, not just the one about to be reduced: the venues gate + * position liquidation on `hasRestingOrderDelta` across the whole + * portfolio, so orders left resting anywhere block the position leg + * everywhere. That is not incidental — a position on one venue can be the + * only thing offsetting resting orders on another, and closing it would + * raise the requirement rather than relieve it. * 4. If still unhealthy: pick the most-underwater venue (max summed * `unrealizedLoss` across its positions) and call `reduceToTarget(user)` * — ONE batched tx that closes the venue's worst-first positions down to @@ -289,6 +296,17 @@ export class Planner { * DESC); tiebreak is summed `notional` DESC (the bigger book frees more * margin when reduced). Venues with no positions are omitted — the position * leg only ever calls `reduceToTarget` on venues that have something to close. + * + * This is a standalone-loss ranking and deliberately coarser than the engine's + * own arithmetic: the summed per-position losses are neither what the futures + * venue reports (it nets its expiries into one signed number) nor what MM charges + * (it nets across venues), so a venue can rank first here while contributing + * nothing to the requirement. Which venue goes first only affects how many + * `reduceToTarget` rounds it takes to converge — each round re-reads health and + * `reduceToTarget` itself sizes the close against the real requirement — so the + * gap costs transactions, never correctness. Closing it means ranking on the + * portfolio requirement instead of per-venue positions, which is a bigger change + * than this loop. */ private async rankVenuesByLoss( user: Address, diff --git a/keeper/src/predict/coordinator.ts b/keeper/src/predict/coordinator.ts index 5ec133e..af606ca 100644 --- a/keeper/src/predict/coordinator.ts +++ b/keeper/src/predict/coordinator.ts @@ -9,9 +9,12 @@ import type { PriceFeed, PriceUpdate } from "../oracle/priceFeed.ts"; import type { Notifier } from "../alert/notifier.ts"; import { readAccountHealthBatch } from "../pme/health.ts"; import { readAccountSnapshot, readMMParams } from "./snapshot.ts"; -import { solveAlertThresholds, solveLiquidationThresholds } from "./solve.ts"; +import { + type MMParams, + solveAlertThresholds, + solveLiquidationThresholds, +} from "@hashpower/portfolio-margin"; import { PredictiveIndex } from "./predictiveIndex.ts"; -import type { MMParams } from "./types.ts"; /** * Wires the predictive layer into the existing keeper: diff --git a/keeper/src/predict/mm.ts b/keeper/src/predict/mm.ts deleted file mode 100644 index 5f21d5d..0000000 --- a/keeper/src/predict/mm.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { AccountSnapshot, MMParams } from "./types.ts"; - -/** - * Off-chain replica of `PortfolioMarginEngine._computeMargin`, restricted to - * the pure-delta case (no options Greeks). The contract's stress engine is: - * - * netDelta = perpDelta + futuresDelta - * = (perpNetQty * WAD / 10^perpQtyDecimals) + getNetPositionDelta() - * - * stressLossWad = max over 4 (±spotShock, ±volShock) scenarios of - * max(0, -(netDelta * deltaS / WAD + ½γ(deltaS)² + ν * deltaVol)) - * - * For our pure-delta portfolios (γ=ν=0), the worst scenario is the one where - * `deltaS` opposes `netDelta`, giving `|netDelta| * spotShock * P / WAD²` - * in WAD. We then rescale to token decimals exactly the way `_fromWad` does. - * - * Plus the four constant or P-piecewise-linear add-ons: - * - perp.orderMargin (constant) - * - perp.unrealizedLoss = max(0, -((P - entry) * netQty / qtyScale)) - * - futures.orderMargin (constant) - * - futures.unrealizedLoss = sum_i max(0, -(P * netQty_i - netEntryValue_i)) - * - perp.fundingOwed (constant — short-term, refreshed on snapshot) - * - * Total mmRequired(P) is therefore piecewise-linear with kinks at the - * per-leg break-even prices. We deliberately keep the math straight (no - * over-engineered piecewise representation) — `mmRequired` is cheap, the - * solver bisects when it matters, and the closed-form solver invokes this - * to verify its candidate roots. - * - * All bigint arithmetic. Token-decimal rounding matches PME's integer division. - */ - -const WAD = 10n ** 18n; - -function abs(x: bigint): bigint { - return x < 0n ? -x : x; -} - -/** - * Aggregate net delta in WAD (matches `_aggregateGreeks` for pure-delta). - * - * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals - * futuresDelta = sum_i netQuantity_i * WAD - * - * Note: the on-chain `getNetPositionDelta` already returns this sum for the - * futures leg in WAD; we re-derive it here off-chain because the snapshot - * carries per-expiry aggregates (needed for per-leg PnL kinks) and re-using - * them avoids a second contract call. Both paths converge on the same value. - */ -export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { - const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); - let delta = (snap.perp.netQty * WAD) / perpQtyScale; - for (const pos of snap.futures.positions) { - delta += pos.netQuantity * WAD; - } - return delta; -} - -/** - * Scale a WAD-denominated value down to token decimals using PME's exact - * convention (integer division by `10^(18 - tokenDecimals)`). - */ -function fromWad(wadValue: bigint, tokenDecimals: number): bigint { - return wadValue / 10n ** BigInt(18 - tokenDecimals); -} - -/** - * Stress loss in token decimals. Pure-delta worst case: - * - * |delta| * shock * P_wad / WAD² (in WAD) - * - * where P_wad = P_token * 10^(18 - tokenDecimals). - * - * Equivalent to the on-chain 4-scenario max in the absence of γ and ν. - */ -export function stressLoss( - delta: bigint, - shock: bigint, - P: bigint, - tokenDecimals: number, -): bigint { - const Pwad = P * 10n ** BigInt(18 - tokenDecimals); - const stressWad = (abs(delta) * shock * Pwad) / (WAD * WAD); - return fromWad(stressWad, tokenDecimals); -} - -/** - * Perp unrealized loss at price P. - * - * pnl = (P - entry) * netQty / 10^perpQtyDecimals - * loss = max(0, -pnl) - */ -export function perpUnrealizedLoss(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { - if (snap.perp.netQty === 0n) return 0n; - const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); - const pnl = ((P - snap.perp.entryPrice) * snap.perp.netQty) / perpQtyScale; - return pnl < 0n ? -pnl : 0n; -} - -/** - * Sum of per-expiry futures unrealized losses at price P. Each whole contract - * settles `pricePerDay` of notional (no duration multiplier): - * - * pnl = P * netQuantity - netEntryValue - * loss = max(0, -pnl) - */ -export function futuresUnrealizedLoss(snap: AccountSnapshot, P: bigint): bigint { - let sum = 0n; - for (const pos of snap.futures.positions) { - const pnl = P * pos.netQuantity - pos.netEntryValue; - if (pnl < 0n) sum += -pnl; - } - return sum; -} - -/** - * Maintenance-margin requirement at price P. Mirrors PME's - * `_computeMargin(user, isIM=false)` for pure-delta portfolios. - */ -export function mmRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { - const delta = netDeltaWad(snap, params); - return ( - stressLoss(delta, params.mmSpotShock, P, params.tokenDecimals) + - snap.perp.orderMargin + - snap.futures.orderMargin + - perpUnrealizedLoss(snap, params, P) + - futuresUnrealizedLoss(snap, P) + - snap.perp.fundingOwed - ); -} - -/** - * Initial-margin requirement at price P. Same shape, swap mmShock → imShock. - */ -export function imRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { - const delta = netDeltaWad(snap, params); - return ( - stressLoss(delta, params.imSpotShock, P, params.tokenDecimals) + - snap.perp.orderMargin + - snap.futures.orderMargin + - perpUnrealizedLoss(snap, params, P) + - futuresUnrealizedLoss(snap, P) + - snap.perp.fundingOwed - ); -} - -/** `balance - mmRequired(P)`. Negative = liquidatable. */ -export function mmSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { - return snap.balance - mmRequired(snap, params, P); -} - -/** `balance - imRequired(P)`. Negative = below IM (warn / critical band). */ -export function imSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { - return snap.balance - imRequired(snap, params, P); -} diff --git a/keeper/src/predict/predictiveIndex.ts b/keeper/src/predict/predictiveIndex.ts index dac68c7..eb090a9 100644 --- a/keeper/src/predict/predictiveIndex.ts +++ b/keeper/src/predict/predictiveIndex.ts @@ -1,5 +1,5 @@ import type { Address } from "viem"; -import type { PriceThresholds } from "./types.ts"; +import type { PriceThresholds } from "@hashpower/portfolio-margin"; /** * Crossings emitted by `PredictiveIndex.crossings(prev, next)`. The diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index ace408e..718e9ad 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -1,11 +1,11 @@ -import type { Address } from "viem"; +import { type Address, erc20Abi } from "viem"; import type { Chain } from "../chain.ts"; 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 type { AccountSnapshot, MMParams } from "./types.ts"; +import type { AccountSnapshot, MMParams } from "@hashpower/portfolio-margin"; /** * Read the engine-wide constants once. They only change on PME admin @@ -17,6 +17,14 @@ export async function readMMParams( chain: Chain, config: Config, ): Promise { + // Token decimals come from the vault's collateral token — the venues no + // longer expose `decimals()` (the PME caches it from the same source). + const collateralToken = await chain.publicClient.readContract({ + address: config.vault.address, + abi: CollateralVaultAbi, + functionName: "collateralToken", + }); + const reads = await chain.publicClient.multicall({ contracts: [ { @@ -30,8 +38,8 @@ export async function readMMParams( functionName: "mmSpotShock" as const, }, { - address: config.perps.address, - abi: HashPowerPerpsDEXAbi, + address: collateralToken, + abi: erc20Abi, functionName: "decimals" as const, }, { @@ -55,12 +63,21 @@ 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, perp position/orderMargin/funding, - * futures orderMargin/activeExpirationAts. - * 2. Per-expiry multicall: hydrate each aggregate via `getUserPosition`. + * 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. * * Round-trip 2 collapses to zero calls when the user has no futures * positions (the common case for perps-only users). + * + * `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 + * fill loss at whatever price it is evaluating. Pending funding also rides in + * `getRiskView`, replacing the separate `getPendingFunding` read. */ export async function readAccountSnapshot( chain: Chain, @@ -70,9 +87,10 @@ export async function readAccountSnapshot( const [ balance, perpPosition, - perpOrderMargin, - perpFunding, - futuresOrderMargin, + perpRisk, + perpOrderValues, + futuresRisk, + futuresOrderValues, activeExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ @@ -91,19 +109,25 @@ export async function readAccountSnapshot( { address: config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "getOrderMargin" as const, + functionName: "getRiskView" as const, args: [user] as const, }, { address: config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "getPendingFunding" as const, + functionName: "getOrderValues" as const, args: [user] as const, }, { address: config.futures.address, abi: FuturesAbi, - functionName: "getOrderMargin" as const, + functionName: "getRiskView" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: FuturesAbi, + functionName: "getOrderValues" as const, args: [user] as const, }, { @@ -119,17 +143,26 @@ export async function readAccountSnapshot( const expirationAts = activeExpirationAts as readonly bigint[]; const futuresPositions: AccountSnapshot["futures"]["positions"] = []; if (expirationAts.length > 0) { - const positions = await chain.publicClient.multicall({ - contracts: expirationAts.map((expirationAt) => ({ - address: config.futures.address, - abi: FuturesAbi, - functionName: "getUserPosition" as const, - args: [user, expirationAt] as const, - })), + const perExpiry = await chain.publicClient.multicall({ + contracts: [ + ...expirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: FuturesAbi, + functionName: "getUserPosition" as const, + args: [user, expirationAt] as const, + })), + ...expirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: FuturesAbi, + functionName: "settlementPrice" as const, + args: [expirationAt] as const, + })), + ], allowFailure: false, }); for (let i = 0; i < expirationAts.length; i++) { - const pos = positions[i]; + const pos = perExpiry[i] as { netQuantity: bigint; netEntryValue: bigint } | undefined; + const settlementPrice = perExpiry[expirationAts.length + i] as bigint | undefined; const expirationAt = expirationAts[i]; if (pos === undefined || expirationAt === undefined) continue; if (pos.netQuantity === 0n) continue; @@ -137,24 +170,38 @@ export async function readAccountSnapshot( expirationAt, netQuantity: pos.netQuantity, netEntryValue: pos.netEntryValue, + settlementPrice: settlementPrice ?? 0n, }); } } - const funding = perpFunding as bigint; + const funding = perpRisk.pendingFunding; return { user, balance: balance as bigint, perp: { netQty: perpPosition.netQuantity, entryPrice: perpPosition.aggregatedEntryPrice, - orderMargin: perpOrderMargin as bigint, + orders: restingOrders(perpRisk, perpOrderValues), // PME uses `max(0, pendingFunding)` — only what the user owes. fundingOwed: funding > 0n ? funding : 0n, }, futures: { positions: futuresPositions, - orderMargin: futuresOrderMargin as bigint, + orders: restingOrders(futuresRisk, futuresOrderValues), }, }; } + +/** Pair a venue's `getRiskView` deltas with its `getOrderValues` limit-price totals. */ +function restingOrders( + risk: { buyOrderDelta: bigint; sellOrderDelta: bigint }, + values: readonly [bigint, bigint], +): AccountSnapshot["perp"]["orders"] { + return { + buyDelta: risk.buyOrderDelta, + sellDelta: risk.sellOrderDelta, + buyValue: values[0], + sellValue: values[1], + }; +} diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts deleted file mode 100644 index f3f4bbf..0000000 --- a/keeper/src/predict/solve.ts +++ /dev/null @@ -1,508 +0,0 @@ -import type { AccountSnapshot, AlertThresholds, FuturesCloseLeg, MMParams, PriceThresholds } from "./types.ts"; -import { imRequired, imSurplus, mmSurplus } from "./mm.ts"; - -function abs(x: bigint): bigint { - return x < 0n ? -x : x; -} - -/** Average entry price for an aggregate (`|netEntryValue| / |netQuantity|`). */ -function avgEntry(pos: AccountSnapshot["futures"]["positions"][number]): bigint { - const absNet = abs(pos.netQuantity); - if (absNet === 0n) return 0n; - return abs(pos.netEntryValue) / absNet; -} - -/** - * Find the price thresholds where `mmSurplus(P)` crosses zero. - * - * `mmRequired(P)` is piecewise-linear in P with kinks at each leg's - * break-even price (perp entry, each futures position's entry-per-day). - * Stress is `|delta| × shock × P / WAD` after rescaling — strictly - * non-decreasing in P for fixed |delta|. - * - * For a typical net-long portfolio, `mmSurplus(P)` is therefore a tent shape: - * - Climbs as P rises (PnL recovers faster than stress grows) until the - * last losing leg breaks even. - * - Above all break-even prices, only stress contributes — `mmSurplus(P)` - * declines linearly to negative infinity as P → ∞. - * Net-short portfolios mirror this around an inverted apex. - * - * We don't try to derive a single closed form for the general piecewise - * landscape — between leg counts, sign mixes, and stress magnitude vs. - * leverage, the case analysis is fragile. Instead we: - * - * 1. Enumerate the kink prices (perp entry + each futures entry). - * 2. Bisect on each side of the current price (down and up) on intervals - * bounded by adjacent kinks. `mmSurplus(P)` is monotone within each - * interval, so a standard bisection converges in O(log) per interval. - * 3. Return the closest crossings on either side of `currentPrice`. - * - * O(K · log(2^60)) per user where K is the number of kinks (≤ #futures - * positions + 1). At keeper scale (≤ a few positions per user), this is a - * handful of µs of pure CPU work — negligible vs the RPC the snapshot read - * already cost. - */ -export function solveLiquidationThresholds( - snap: AccountSnapshot, - params: MMParams, - currentPrice: bigint, -): PriceThresholds { - // Already underwater → no useful threshold; the caller should liquidate - // immediately rather than wait for a future price tick. - if (mmSurplus(snap, params, currentPrice) < 0n) { - return { user: snap.user, liqDown: undefined, liqUp: undefined }; - } - const result = findClosestCrossings(snap, currentPrice, (P) => mmSurplus(snap, params, P)); - return { user: snap.user, liqDown: result.down, liqUp: result.up }; -} - -/** - * Find the prices at which the user's IM utilization (`imRequired / balance`) - * crosses the warn and critical thresholds. Used by the predictive - * coordinator to fire alerts *before* the next sweep tick discovers them. - * - * For each level we solve `imRequired(P) - level * balance = 0`. Returns - * `undefined` for any side that's never crossed (e.g. a flat user can't be - * pushed into IM-warn by price moves). Already past the threshold at - * `currentPrice` → returns `undefined` for that level (the sweep-driven - * alert path will catch it on the next tick). - */ -export function solveAlertThresholds( - snap: AccountSnapshot, - params: MMParams, - currentPrice: bigint, - warnUtilizationPpm: bigint, - criticalUtilizationPpm: bigint, -): AlertThresholds { - // No collateral → no IM utilization is well-defined; sweep handles it. - if (snap.balance <= 0n) { - return { - user: snap.user, - warnDown: undefined, - warnUp: undefined, - critDown: undefined, - critUp: undefined, - }; - } - // Target ppm scaling: imRequired - util * balance = imRequired - (utilPpm * balance) / 1e6 - const PPM = 1_000_000n; - const warnTarget = (warnUtilizationPpm * snap.balance) / PPM; - const critTarget = (criticalUtilizationPpm * snap.balance) / PPM; - const f = (target: bigint) => (P: bigint) => imRequired(snap, params, P) - target; - - // For an alert level we want price points where `imRequired(P) = target`. - // Already at-or-over the target at currentPrice → that level isn't a - // forward-looking trigger; the sweep alert path will fire it. - const warn = - imRequired(snap, params, currentPrice) >= warnTarget - ? { down: undefined, up: undefined } - : findClosestCrossings(snap, currentPrice, f(warnTarget)); - const crit = - imRequired(snap, params, currentPrice) >= critTarget - ? { down: undefined, up: undefined } - : findClosestCrossings(snap, currentPrice, f(critTarget)); - return { - user: snap.user, - warnDown: warn.down, - warnUp: warn.up, - critDown: crit.down, - critUp: crit.up, - }; -} - -/** - * Generic: find the closest prices on either side of `currentPrice` where - * the supplied `f` function crosses zero. Uses the same kink-driven - * piecewise-monotone bisection as `solveLiquidationThresholds`, parameterised - * so multiple solvers (liq, im-warn, im-crit) can share the engine. - * - * Sign-convention agnostic: detects crossings regardless of which sign - * means "safe". Callers are responsible for short-circuiting when - * currentPrice is already past the threshold of interest. - */ -function findClosestCrossings( - snap: AccountSnapshot, - currentPrice: bigint, - f: (P: bigint) => bigint, -): { down: bigint | undefined; up: bigint | undefined } { - const kinks: bigint[] = []; - if (snap.perp.netQty !== 0n) kinks.push(snap.perp.entryPrice); - for (const pos of snap.futures.positions) { - if (pos.netQuantity !== 0n) kinks.push(avgEntry(pos)); - } - kinks.push(currentPrice); - kinks.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - const dedup: bigint[] = []; - for (const k of kinks) { - if (dedup.length === 0 || dedup[dedup.length - 1] !== k) dedup.push(k); - } - - const lastDedup = dedup[dedup.length - 1] ?? currentPrice; - const upperCap = lastDedup * 1024n + 1n; - const lowerCap = 1n; - - const intervals: Array<[bigint, bigint]> = []; - let prev = lowerCap; - for (const k of dedup) { - if (k > prev) intervals.push([prev, k]); - prev = k; - } - if (upperCap > prev) intervals.push([prev, upperCap]); - - let down: bigint | undefined; - let up: bigint | undefined; - - for (const [lo, hi] of intervals) { - const sLo = f(lo); - const sHi = f(hi); - if ((sLo > 0n && sHi > 0n) || (sLo < 0n && sHi < 0n)) continue; - if (sLo === 0n) { - registerCrossing(lo, currentPrice, (isDown) => { - if (isDown) down = closer(down, lo, currentPrice, true); - else up = closer(up, lo, currentPrice, false); - }); - continue; - } - if (sHi === 0n) { - registerCrossing(hi, currentPrice, (isDown) => { - if (isDown) down = closer(down, hi, currentPrice, true); - else up = closer(up, hi, currentPrice, false); - }); - continue; - } - const root = bisect(lo, hi, sLo, f); - if (root < currentPrice) down = closer(down, root, currentPrice, true); - else if (root > currentPrice) up = closer(up, root, currentPrice, false); - } - - return { down, up }; -} - -// ─────────────────────────────────────────────────────────────────────────── -// Close-to-IM-buffer sizing (the batched-liquidation solvers) -// -// The on-chain `liquidatePositions` (futures) / `liquidatePosition(user, -// closeQty)` (perps) treat the keeper-supplied amount as an upper bound and -// revert `OverLiquidation` when a partial leaves balance above IM with a real -// IM buffer (`im > mm`). These solvers pick, off-chain, the deepest close that -// keeps the account inside the `[MM, IM]` band (healthy but not -// over-liquidated). If no in-band partial exists (deep crash / bad debt) they -// fall back to a full close, which the contract lets through (the guard is -// skipped once no positions remain). -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Off-chain replica of the futures batch close: reduce each aggregate toward - * zero by `closeQty` and debit realized PnL + flat fee per expiry leg. - * Mirrors `Futures._doPartialLiquidatePosition` / `_doLiquidateFullPosition`. - */ -export function simulateFuturesClose( - snap: AccountSnapshot, - closes: readonly FuturesCloseLeg[], - currentPrice: bigint, - liquidationFee: bigint, -): AccountSnapshot { - const closeByExpiry = new Map(); - for (const c of closes) { - closeByExpiry.set(c.expirationAt, (closeByExpiry.get(c.expirationAt) ?? 0n) + c.closeQty); - } - - const remaining: AccountSnapshot["futures"]["positions"] = []; - let balanceDelta = 0n; - for (const pos of snap.futures.positions) { - const want = closeByExpiry.get(pos.expirationAt) ?? 0n; - if (want <= 0n) { - remaining.push(pos); - continue; - } - const absNet = abs(pos.netQuantity); - const closeAbs = want < absNet ? want : absNet; - if (closeAbs <= 0n) { - remaining.push(pos); - continue; - } - - const entry = avgEntry(pos); - const signedClose = pos.netQuantity > 0n ? closeAbs : -closeAbs; - const pnl = (currentPrice - entry) * signedClose; - balanceDelta += pnl - liquidationFee; - - if (closeAbs >= absNet) continue; - const newAbs = absNet - closeAbs; - remaining.push({ - expirationAt: pos.expirationAt, - netQuantity: pos.netQuantity > 0n ? newAbs : -newAbs, - netEntryValue: (pos.netEntryValue * newAbs) / absNet, - }); - } - - return { - ...snap, - balance: snap.balance + balanceDelta, - futures: { ...snap.futures, positions: remaining }, - }; -} - -/** - * Off-chain replica of the perps partial close: reduce `netQty` toward zero by - * `min(closeQty, |netQty|)` and debit the realized PnL on that slice plus the - * single flat fee. Mirrors `HashPowerPerpsDEX._doPartialLiquidatePosition` - * (`_settleReducedPosition` + one `liquidationFee`). Entry price unchanged. - */ -export function simulatePerpClose( - snap: AccountSnapshot, - closeQty: bigint, - currentPrice: bigint, - liquidationFee: bigint, -): AccountSnapshot { - const netQty = snap.perp.netQty; - const absNet = netQty < 0n ? -netQty : netQty; - const closeAbs = closeQty < absNet ? closeQty : absNet; - if (closeAbs <= 0n) return snap; - - const isLong = netQty > 0n; - const signedClose = isLong ? closeAbs : -closeAbs; - // Perps quantities are scaled by 10^QUANTITY_DECIMALS (=6 in HashPowerPerpsDEX); - // matches `perpUnrealizedLoss` in mm.ts and the venue's QUANTITY_SCALE. - const qtyScale = 10n ** 6n; - const pnl = ((currentPrice - snap.perp.entryPrice) * signedClose) / qtyScale; - const newNetQty = isLong ? netQty - closeAbs : netQty + closeAbs; - return { - ...snap, - balance: snap.balance + pnl - liquidationFee, - perp: { ...snap.perp, netQty: newNetQty }, - }; -} - -/** - * Pick per-expiry `closeQty` legs so the account lands inside the `[MM, IM]` - * band. Unit closes are ranked worst-first and interleaved across expiries - * (round-robin) so a prefix does not drain one book before touching another. - * Returns `[]` if already healthy, or a full close of every aggregate when no - * in-band partial exists (deep crash / bad debt). - */ -export function solveFuturesClosesToTarget( - snap: AccountSnapshot, - params: MMParams, - currentPrice: bigint, - liquidationFee: bigint, -): FuturesCloseLeg[] { - const positions = snap.futures.positions; - if (positions.length === 0) return []; - if (mmSurplus(snap, params, currentPrice) >= 0n) return []; - - const hasBuffer = params.imSpotShock > params.mmSpotShock; - const unitSequence = rankUnitClosesBalancedAcrossExpirations(positions, currentPrice); - const n = unitSequence.length; - if (n === 0) return []; - - let bestPrefix = 0; - let foundInBand = false; - for (let k = 1; k <= n; k++) { - const closes = coalesceUnitPrefix(unitSequence, k); - const after = simulateFuturesClose(snap, closes, currentPrice, liquidationFee); - const mmS = mmSurplus(after, params, currentPrice); - const imS = imSurplus(after, params, currentPrice); - if (!hasBuffer) { - if (mmS >= 0n) { - bestPrefix = k; - foundInBand = true; - break; - } - continue; - } - if (mmS >= 0n && imS <= 0n) { - bestPrefix = k; - foundInBand = true; - } - if (imS > 0n) break; - } - - if (!foundInBand) { - // Full close every aggregate. - return positions.map((p) => ({ - expirationAt: p.expirationAt, - closeQty: abs(p.netQuantity), - })); - } - // Emit 1-qty legs in round-robin order (not coalesced/sorted by expiry). - // `liquidatePositions` stops once healthy; coalescing into [A:N, B:M] would - // drain A first and skip B. Interleaved unit legs keep the prefix balanced. - return unitSequence.slice(0, bestPrefix).map((expirationAt) => ({ - expirationAt, - closeQty: 1n, - })); -} - -/** - * Pick the absolute `closeQty` (scaled by perp quantity decimals) to partially - * close a perps position down into the `[MM, IM]` band. `mmSurplus` and - * `imSurplus` are both monotone increasing in the closed quantity, so we - * bisect: with a real IM buffer we take the deepest close that stays at/under - * IM (which is automatically ≥ the minimal-healthy amount); degenerate - * `IM == MM` targets minimal-healthy. Returns `0n` if already healthy, or - * `|netQty|` (full close) when even closing everything can't reach the band - * (deep crash / bad debt). - */ -export function solvePerpCloseToTarget( - snap: AccountSnapshot, - params: MMParams, - currentPrice: bigint, - liquidationFee: bigint, -): bigint { - const netQty = snap.perp.netQty; - const absNet = netQty < 0n ? -netQty : netQty; - if (absNet === 0n) return 0n; - if (mmSurplus(snap, params, currentPrice) >= 0n) return 0n; - - const mmS = (q: bigint) => - mmSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); - const imS = (q: bigint) => - imSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); - - const hasBuffer = params.imSpotShock > params.mmSpotShock; - - if (!hasBuffer) { - // Minimal healthy close; if even a full close can't heal, full close. - if (mmS(absNet) < 0n) return absNet; - const qHealthy = firstQtyWhere(mmS, absNet); - return qHealthy >= absNet ? absNet : qHealthy; - } - - // If even a full close leaves the account under MM, it's bad debt — close all. - if (mmS(absNet) < 0n) return absNet; - - // Deepest close that stays at/under IM = (first q where imSurplus > 0) − 1. - // If IM surplus never turns positive before a full close, the whole position - // is bad-debt-adjacent → full close. - if (imS(absNet) <= 0n) return absNet; - const qOverIM = firstQtyWhere((q) => (imS(q) > 0n ? 1n : -1n), absNet); - const qStar = qOverIM - 1n; - return qStar >= absNet ? absNet : qStar < 0n ? 0n : qStar; -} - -/** - * Smallest `q` in `[0, hi]` at which the monotone-increasing `f(q)` becomes - * `>= 0`. Assumes `f(0) < 0` and `f(hi) >= 0` (callers guarantee this via the - * healthy / bad-debt short-circuits). Bisection in scaled quantity units. - */ -function firstQtyWhere(f: (q: bigint) => bigint, hi: bigint): bigint { - let a = 0n; - let b = hi; - if (f(b) < 0n) return hi; - if (f(a) >= 0n) return 0n; - while (b - a > 1n) { - const m = (a + b) / 2n; - if (f(m) >= 0n) b = m; - else a = m; - } - return b; -} - -type FuturesAggregate = AccountSnapshot["futures"]["positions"][number]; - -/** - * Expand aggregates into a unit-close sequence interleaved across expiries. - * Each unit is one whole contract at a `expirationAt`. Groups (expiries) are - * ordered by total unrealized loss desc; within the sequence we round-robin - * one unit from each group until books are exhausted. - */ -function rankUnitClosesBalancedAcrossExpirations( - positions: readonly FuturesAggregate[], - currentPrice: bigint, -): bigint[] { - const lossOf = (p: FuturesAggregate) => aggregateUnrealizedLoss(p, currentPrice); - const ordered = [...positions] - .filter((p) => p.netQuantity !== 0n) - .sort((a, b) => { - const la = lossOf(a); - const lb = lossOf(b); - if (la !== lb) return la < lb ? 1 : -1; - const na = abs(a.netQuantity) * avgEntry(a); - const nb = abs(b.netQuantity) * avgEntry(b); - if (na !== nb) return na < nb ? 1 : -1; - return a.expirationAt < b.expirationAt ? -1 : a.expirationAt > b.expirationAt ? 1 : 0; - }); - - const remaining = ordered.map((p) => abs(p.netQuantity)); - const result: bigint[] = []; - let progress = true; - while (progress) { - progress = false; - for (let i = 0; i < ordered.length; i++) { - const left = remaining[i] ?? 0n; - if (left <= 0n) continue; - remaining[i] = left - 1n; - result.push(ordered[i]!.expirationAt); - progress = true; - } - } - return result; -} - -function coalesceUnitPrefix(unitSequence: readonly bigint[], prefixLen: number): FuturesCloseLeg[] { - const counts = new Map(); - for (let i = 0; i < prefixLen && i < unitSequence.length; i++) { - const d = unitSequence[i]!; - counts.set(d, (counts.get(d) ?? 0n) + 1n); - } - return [...counts.entries()] - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([expirationAt, closeQty]) => ({ expirationAt, closeQty })); -} - -/** Per-aggregate unrealized loss at `P` (token decimals); 0 when in profit. */ -function aggregateUnrealizedLoss(pos: FuturesAggregate, P: bigint): bigint { - const pnl = P * pos.netQuantity - pos.netEntryValue; - return pnl < 0n ? -pnl : 0n; -} - -/** Bisect within [lo, hi] until the interval shrinks to 1 wei. Assumes a sign change. */ -function bisect( - lo: bigint, - hi: bigint, - sLo: bigint, - f: (P: bigint) => bigint, -): bigint { - let a = lo; - let b = hi; - let sa = sLo; - // Conservative iteration cap: for any 256-bit price the interval halves - // 256 times before becoming 1 wei. We never actually reach that — we exit - // on the (b - a) <= 1 condition first. - for (let i = 0; i < 256; i++) { - if (b - a <= 1n) return sa < 0n ? b : a; - const mid = (a + b) / 2n; - const sm = f(mid); - if (sm === 0n) return mid; - // Maintain invariant: sa and sb have opposite signs. - if ((sa < 0n && sm < 0n) || (sa > 0n && sm > 0n)) { - a = mid; - sa = sm; - } else { - b = mid; - } - } - return a; -} - -function registerCrossing(at: bigint, currentPrice: bigint, sink: (down: boolean) => void): void { - if (at < currentPrice) sink(true); - else if (at > currentPrice) sink(false); -} - -/** - * Pick whichever candidate threshold is *closer* to `currentPrice`. For the - * downside ("liquidatable when spot falls below"), closer means the one - * with the higher price; for the upside, the one with the lower price. - */ -function closer( - prev: bigint | undefined, - candidate: bigint, - _currentPrice: bigint, - isDown: boolean, -): bigint { - if (prev === undefined) return candidate; - if (isDown) return candidate > prev ? candidate : prev; - return candidate < prev ? candidate : prev; -} diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts deleted file mode 100644 index 519a856..0000000 --- a/keeper/src/predict/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Address } from "viem"; - -/** - * Per-account inputs needed to evaluate `mmRequired(P)` and `imRequired(P)` - * off-chain at an arbitrary spot price `P`. Captured as a snapshot so the - * predictor can re-evaluate at any new price without further RPC reads. - * - * Shapes deliberately mirror the on-chain getters: - * - perps: `getUserPosition` + `getOrderMargin` + `getPendingFunding` - * - futures: `getActiveExpirationDates`/`getUserPosition` + `getOrderMargin` - * - * Bigints throughout because PME math is performed in token-decimal units - * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers - * lose precision at the dollar level for typical position sizes. - */ -export interface AccountSnapshot { - user: Address; - /** Vault balance (token decimals). */ - balance: bigint; - - /** Perps single netted position (zero-qty if user has no perp exposure). */ - perp: { - /** Signed; +long, −short. Scaled by 10^perpQuantityDecimals. */ - netQty: bigint; - /** Token decimals (matches `getMarketPrice`). */ - entryPrice: bigint; - /** Constant in P: `getOrderMargin(user)` (token decimals). */ - orderMargin: bigint; - /** `max(0, getPendingFunding(user))` snapshot (token decimals). */ - fundingOwed: bigint; - }; - - /** - * One entry per active futures expiry. Unilateral aggregate per - * `(user, expirationAt)`: signed `netQuantity` (whole contracts) + - * `netEntryValue` (token decimals) so unrealized PnL is - * `P * netQuantity - netEntryValue`. - */ - futures: { - positions: Array<{ - expirationAt: bigint; - /** Signed whole contracts (+long / −short). */ - netQuantity: bigint; - /** Token decimals; `sum(fillPrice * signedFillQty)`. */ - netEntryValue: bigint; - }>; - /** Constant in P: `getOrderMargin(user)`. */ - orderMargin: bigint; - }; -} - -/** - * Engine-wide constants needed by the off-chain MM math. Read once during - * snapshot setup and cached — they only change on PME admin transactions. - */ -export interface MMParams { - /** WAD-scaled (e.g. 0.05e18 = 5%). */ - imSpotShock: bigint; - /** WAD-scaled (e.g. 0.10e18 = 10%). */ - mmSpotShock: bigint; - /** Decimals of the venues' answer (USDC = 6). */ - tokenDecimals: number; - /** Perps quantity decimals (typically 6). */ - perpQuantityDecimals: number; -} - -/** - * Per-account price thresholds derived from the snapshot. `undefined` means - * the user is structurally not liquidatable on that side (e.g. flat or - * already deeply healthy at any plausible price). - */ -export interface PriceThresholds { - user: Address; - /** Liquidatable when spot drops to or below this. */ - liqDown: bigint | undefined; - /** Liquidatable when spot rises to or above this. */ - liqUp: bigint | undefined; -} - -/** - * Per-account IM-utilization alert thresholds. Same {down, up} pattern as - * `PriceThresholds`, just one set per severity. `undefined` on a level - * means the user is already over (or structurally cannot reach) that - * level — the sweep alert path covers the "already over" case. - */ -export interface AlertThresholds { - user: Address; - warnDown: bigint | undefined; - warnUp: bigint | undefined; - critDown: bigint | undefined; - critUp: bigint | undefined; -} - -/** One expiry leg of a futures close-to-IM batch. */ -export interface FuturesCloseLeg { - expirationAt: bigint; - /** Absolute contracts to close toward zero (≤ |netQuantity|). */ - closeQty: bigint; -} diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 7715f2e..18b4f08 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -5,8 +5,7 @@ import type { Config } from "../config.ts"; import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; -import { solveFuturesClosesToTarget } from "../predict/solve.ts"; -import type { MMParams } from "../predict/types.ts"; +import { type MMParams, solveFuturesClosesToTarget } from "@hashpower/portfolio-margin"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index 1bcb717..228387f 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -5,8 +5,7 @@ import type { Config } from "../config.ts"; import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; -import { solvePerpCloseToTarget } from "../predict/solve.ts"; -import type { MMParams } from "../predict/types.ts"; +import { type MMParams, solvePerpCloseToTarget } from "@hashpower/portfolio-margin"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import type { LiquidateOrdersOutcome, diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index 3ef5039..03c3b19 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -96,7 +96,8 @@ export interface Venue { * closeQtys[])`, perps `liquidatePosition(user, closeQty)`. * Oversize partials revert `OverLiquidation` (re-size off-chain). * - * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first) or + * Reverts on-chain with `OrdersStillOpen` (orders must be cleared first — + * across the whole portfolio, not just this venue's book) or * `NotLiquidatable` are translated into `{ skipped }` so the planner * re-plans without crashing. */ diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 5ac92c5..a29da42 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -108,11 +108,12 @@ export interface DeployedStack { initialBtcUsdc: bigint; minimumPriceIncrement: bigint; quantityDecimals: number; - perpsLiquidationFee: bigint; + perpsLiquidationFeeBps: bigint; perpsTakerFeeBps: bigint; perpsMakerFeeBps: bigint; - futuresTakerFee: bigint; - futuresLiquidationFee: bigint; + futuresMakerFeeBps: bigint; + futuresTakerFeeBps: bigint; + futuresLiquidationFeeBps: bigint; futuresFirstExpirationAt: bigint; insuranceFund: bigint; initialUserBalance: bigint; @@ -136,11 +137,16 @@ const INITIAL_HASHPRICE = INITIAL_MARKET_PRICE; const INITIAL_BTC_USDC = parseUnits("65000", ORACLE_DECIMALS); const MIN_PRICE_INCREMENT = parseUnits("0.01", TOKEN_DECIMALS); -const PERPS_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); +// Venue fees are basis-point based (bps of notional). 50 bps = 0.5% mirrors the +// liquidation-fee values the perps/futures repos use in their own test suites. +const PERPS_LIQUIDATION_FEE_BPS = 50n; const PERPS_TAKER_FEE_BPS = 5n; const PERPS_MAKER_FEE_BPS = 0n; -const FUTURES_TAKER_FEE = parseUnits("1", TOKEN_DECIMALS); -const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); +// Futures match fees default to 0 (futures repo fixture convention) — scenarios +// that need fees set them explicitly. +const FUTURES_MAKER_FEE_BPS = 0n; +const FUTURES_TAKER_FEE_BPS = 0n; +const FUTURES_LIQUIDATION_FEE_BPS = 50n; const FUTURES_LIQUIDATION_MARGIN_PCT = 20; /** Spacing, in days, between successive expiries — must match Futures.EXPIRATION_INTERVAL_DAYS (30). */ const FUTURES_EXPIRATION_INTERVAL_DAYS = 30; @@ -220,11 +226,9 @@ export async function deployStack(rpcUrl: string): Promise { [usdc], ); - // ── Perps (UUPS proxy) ──────────────────────────────────────────────── + // ── Perps (UUPS proxy; vault is an immutable constructor arg) ───────── const perpsArt = artifacts.perps(); - const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [ - MIN_PRICE_INCREMENT, - ]); + const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [vault]); const perps = await deployProxy( publicClient, owner.client, @@ -271,15 +275,20 @@ export async function deployStack(rpcUrl: string): Promise { pmeImpl, pmeArt.abi, "initialize", - [vault], + [], ); // ── Wire PME ↔ venues ↔ vault ───────────────────────────────────────── - // PME -> learn about each venue so portfolio MM math includes both legs. - await write(publicClient, owner.client, pme, pmeArt.abi, "setPerps", [perps]); - await write(publicClient, owner.client, pme, pmeArt.abi, "setFutures", [ + // PME -> learn about each venue so portfolio MM math includes both legs, + // and point its own spot source at the shared hashprice oracle. + await write(publicClient, owner.client, pme, pmeArt.abi, "setVault", [vault]); + await write(publicClient, owner.client, pme, pmeArt.abi, "addLinearMarket", [perps]); + await write(publicClient, owner.client, pme, pmeArt.abi, "addLinearMarket", [ futures, ]); + await write(publicClient, owner.client, pme, pmeArt.abi, "setOracle", [ + hashpriceOracle, + ]); // Vault -> point at the single margin engine + authorize each venue. await write( @@ -307,7 +316,7 @@ export async function deployStack(rpcUrl: string): Promise { [futures, true], ); - // Perps -> PME + fee config. + // Perps -> PME + fee config (bps setters). await write( publicClient, owner.client, @@ -316,8 +325,10 @@ export async function deployStack(rpcUrl: string): Promise { "setPortfolioMargin", [pme], ); - await write(publicClient, owner.client, perps, perpsArt.abi, "setMatchFee", [ + await write(publicClient, owner.client, perps, perpsArt.abi, "setTakerFeeBps", [ Number(PERPS_TAKER_FEE_BPS), + ]); + await write(publicClient, owner.client, perps, perpsArt.abi, "setMakerFeeBps", [ Number(PERPS_MAKER_FEE_BPS), ]); await write( @@ -325,11 +336,11 @@ export async function deployStack(rpcUrl: string): Promise { owner.client, perps, perpsArt.abi, - "setLiquidationFee", - [PERPS_LIQUIDATION_FEE], + "setLiquidationFeeBps", + [Number(PERPS_LIQUIDATION_FEE_BPS)], ); - // Futures -> PME + fees. + // Futures -> PME + fees (bps setters). await write( publicClient, owner.client, @@ -343,16 +354,24 @@ export async function deployStack(rpcUrl: string): Promise { owner.client, futures, futuresArt.abi, - "setTakerFee", - [FUTURES_TAKER_FEE], + "setTakerFeeBps", + [Number(FUTURES_TAKER_FEE_BPS)], + ); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setMakerFeeBps", + [Number(FUTURES_MAKER_FEE_BPS)], ); await write( publicClient, owner.client, futures, futuresArt.abi, - "setLiquidationFee", - [FUTURES_LIQUIDATION_FEE], + "setLiquidationFeeBps", + [Number(FUTURES_LIQUIDATION_FEE_BPS)], ); // ── Fund & approve test wallets ─────────────────────────────────────── @@ -412,11 +431,12 @@ export async function deployStack(rpcUrl: string): Promise { initialBtcUsdc: INITIAL_BTC_USDC, minimumPriceIncrement: MIN_PRICE_INCREMENT, quantityDecimals: QUANTITY_DECIMALS, - perpsLiquidationFee: PERPS_LIQUIDATION_FEE, + perpsLiquidationFeeBps: PERPS_LIQUIDATION_FEE_BPS, perpsTakerFeeBps: PERPS_TAKER_FEE_BPS, perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, - futuresTakerFee: FUTURES_TAKER_FEE, - futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, + futuresMakerFeeBps: FUTURES_MAKER_FEE_BPS, + futuresTakerFeeBps: FUTURES_TAKER_FEE_BPS, + futuresLiquidationFeeBps: FUTURES_LIQUIDATION_FEE_BPS, futuresFirstExpirationAt: firstExpirationAt, insuranceFund: INSURANCE_FUND, initialUserBalance: INITIAL_USER_BALANCE, diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index d7af704..f3c12c0 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -2,6 +2,9 @@ import { parseUnits, type Address } from "viem"; import { hardhat } from "viem/chains"; import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; +/** Mirrors the venues' on-chain `TimeInForce`; fixtures only ever rest liquidity. */ +const GTC = 0; + /** * Fixture builders. * @@ -1017,7 +1020,7 @@ async function placePerpsOrder( address: base.addresses.perps, abi: base.abis.perps, functionName: "createOrder", - args: [price, quantity], + args: [price, quantity, GTC], chain: hardhat, account: wallet.account, }); @@ -1031,25 +1034,25 @@ async function placeFuturesOrder( expirationAt: bigint, qty: number, ): Promise { - // Futures 3.0: createOrder(price, expirationAt, signedQuantity) — whole contracts. + // Futures 3.0: createOrder(price, expirationAt, signedQuantity, tif) — whole contracts. const hash = await wallet.client.writeContract({ address: base.addresses.futures, abi: base.abis.futures, functionName: "createOrder", - args: [price, expirationAt, BigInt(qty)], + args: [price, expirationAt, BigInt(qty), GTC], chain: hardhat, account: wallet.account, }); await base.publicClient.waitForTransactionReceipt({ hash }); } -/** Owner-only: set the futures per-lot taker fee (token decimals). */ -async function setFuturesTakerFee(stack: DeployedStack, fee: bigint): Promise { +/** Owner-only: set the futures taker fee (bps of notional). */ +async function setFuturesTakerFee(stack: DeployedStack, feeBps: bigint): Promise { const hash = await stack.accounts.owner.client.writeContract({ address: stack.addresses.futures, abi: stack.abis.futures, - functionName: "setTakerFee", - args: [fee], + functionName: "setTakerFeeBps", + args: [Number(feeBps)], chain: hardhat, account: stack.accounts.owner.account, }); diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 9fffdbe..be7f4f3 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -86,6 +86,9 @@ function buildHarness({ const chain = { publicClient: { readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "collateralToken") { + return "0x000000000000000000000000000000000000aa05"; + } if (functionName === "decimals") return 8; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; @@ -116,10 +119,18 @@ function buildHarness({ return balance; case "getUserPosition": return { netQuantity: perpNetQty, aggregatedEntryPrice: perpEntry }; - case "getOrderMargin": - return 0n; - case "getPendingFunding": - return 0n; + case "getRiskView": + return { + netPositionDelta: 0n, + unrealizedPnl: 0n, + pendingFunding: 0n, + buyOrderDelta: 0n, + sellOrderDelta: 0n, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, + }; + case "getOrderValues": + return [0n, 0n]; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index b551be7..bfaad08 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -63,6 +63,9 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi const chain = { publicClient: { readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "collateralToken") { + return "0x000000000000000000000000000000000000aa05"; + } if (functionName === "decimals") return 8; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; @@ -91,10 +94,18 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return balance; case "getUserPosition": return { netQuantity: 1_000_000n, aggregatedEntryPrice: perpEntry }; - case "getOrderMargin": - return 0n; - case "getPendingFunding": - return 0n; + case "getRiskView": + return { + netPositionDelta: 0n, + unrealizedPnl: 0n, + pendingFunding: 0n, + buyOrderDelta: 0n, + sellOrderDelta: 0n, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, + }; + case "getOrderValues": + return [0n, 0n]; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index b28d630..243fc6a 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -4,12 +4,17 @@ import type { Address } from "viem"; import { readAccountSnapshot, readMMParams } from "../../src/predict/snapshot.ts"; import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; +import type { RestingOrders } from "@hashpower/portfolio-margin"; + +/** An empty book on one venue. */ +const NO_ORDERS: RestingOrders = { buyDelta: 0n, sellDelta: 0n, buyValue: 0n, sellValue: 0n }; const VAULT = "0x000000000000000000000000000000000000aa01" as Address; const PME = "0x000000000000000000000000000000000000aa02" as Address; const PERPS = "0x000000000000000000000000000000000000aa03" as Address; const FUTURES = "0x000000000000000000000000000000000000aa04" as Address; const USER = "0x1111111111111111111111111111111111111111" as Address; +const USDC = "0x000000000000000000000000000000000000aa05" as Address; const EXPIRY_A = 1_756_416_000n; const EXPIRY_B = 1_759_008_000n; @@ -26,11 +31,13 @@ function makeConfig(): Config { function makeChain(scripted: { activeExpirationAts?: readonly bigint[]; futuresPositions?: Record; + /** Keyed by expiry; absent means the expiry has not settled. */ + settlementPrices?: Record; perpNetQty?: bigint; perpEntry?: bigint; - perpOrderMargin?: bigint; perpFunding?: bigint; - futuresOrderMargin?: bigint; + perpOrders?: RestingOrders; + futuresOrders?: RestingOrders; balance?: bigint; imShock?: bigint; mmShock?: bigint; @@ -39,6 +46,10 @@ function makeChain(scripted: { }): Chain { return { publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "collateralToken") return USDC; + throw new Error(`unexpected readContract: ${functionName}`); + }, multicall: async ({ contracts, }: { @@ -61,12 +72,29 @@ function makeChain(scripted: { aggregatedEntryPrice: scripted.perpEntry ?? 0n, }; } - case "getOrderMargin": - return c.address === PERPS - ? scripted.perpOrderMargin ?? 0n - : scripted.futuresOrderMargin ?? 0n; - case "getPendingFunding": - return scripted.perpFunding ?? 0n; + case "settlementPrice": { + const expirationAt = c.args?.[0] as bigint; + return scripted.settlementPrices?.[expirationAt.toString()] ?? 0n; + } + case "getRiskView": { + const orders = + (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; + return { + netPositionDelta: 0n, + unrealizedPnl: 0n, + // Only the perps venue accrues funding. + pendingFunding: c.address === PERPS ? scripted.perpFunding ?? 0n : 0n, + buyOrderDelta: orders.buyDelta, + sellOrderDelta: orders.sellDelta, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, + }; + } + case "getOrderValues": { + const orders = + (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; + return [orders.buyValue, orders.sellValue]; + } case "getActiveExpirationDates": return scripted.activeExpirationAts ?? []; case "imSpotShock": @@ -105,6 +133,30 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.perp.netQty, 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 () => { + const perpOrders: RestingOrders = { + buyDelta: 2_000_000n, + sellDelta: 500_000n, + buyValue: 190_000_000n, + sellValue: 55_000_000n, + }; + const futuresOrders: RestingOrders = { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 42_000_000n, + sellValue: 0n, + }; + const chain = makeChain({ perpOrders, futuresOrders }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + // The snapshot carries limit-price totals rather than the venue's fill loss at the + // current mark, because the clamp makes that figure non-invertible once it reads + // zero and the predictor needs the loss at prices other than the current one. + assert.deepEqual(snap.perp.orders, perpOrders); + assert.deepEqual(snap.futures.orders, futuresOrders); }); it("clamps pending funding to >= 0 (PME treats credits as not-owed)", async () => { @@ -136,4 +188,27 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(short?.netQuantity, -2n); assert.equal(short?.netEntryValue, -118n); }); + + it("hydrates each expiry's settlement price, defaulting unsettled ones to zero", async () => { + const chain = makeChain({ + activeExpirationAts: [EXPIRY_A, EXPIRY_B], + futuresPositions: { + [EXPIRY_A.toString()]: { netQuantity: 1n, netEntryValue: 50n }, + [EXPIRY_B.toString()]: { netQuantity: -2n, netEntryValue: -118n }, + }, + settlementPrices: { [EXPIRY_B.toString()]: 61n }, + }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + + assert.equal( + snap.futures.positions.find((p) => p.expirationAt === EXPIRY_A)?.settlementPrice, + 0n, + "still live", + ); + assert.equal( + snap.futures.positions.find((p) => p.expirationAt === EXPIRY_B)?.settlementPrice, + 61n, + "settled but not yet swept — the margin math must not reprice it", + ); + }); }); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index d85b1ea..bc70084 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -7,6 +7,7 @@ import type { Config } from "../../src/config.ts"; const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; const USER = "0x0000000000000000000000000000000000000b0b" as Address; +const USDC = "0x000000000000000000000000000000000000aa05" as Address; const EXPIRY = 1_756_416_000n; const IM_SHOCK = 10n ** 17n; @@ -17,6 +18,34 @@ interface ReadCall { args?: readonly unknown[]; } +/** `ILinearMarket.RiskView` for an account with no position and an empty book. */ +const EMPTY_RISK_VIEW = { + netPositionDelta: 0n, + unrealizedPnl: 0n, + pendingFunding: 0n, + buyOrderDelta: 0n, + sellOrderDelta: 0n, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, +} 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. + */ +function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { + return [ + balance, + { netQuantity: 0n, aggregatedEntryPrice: 0n }, + EMPTY_RISK_VIEW, + [0n, 0n], + EMPTY_RISK_VIEW, + [0n, 0n], + expiries, + ]; +} + const silentLogger = { child: () => silentLogger, debug: () => undefined, @@ -48,26 +77,23 @@ function makeChainStub(opts: { publicClient: { readContract: async (call: ReadCall) => { if (call.functionName === "getMarketPrice") return opts.marketPrice; + if (call.functionName === "collateralToken") return USDC; throw new Error(`unexpected readContract: ${call.functionName}`); }, multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => { const fns = contracts.map((c) => c.functionName); if (fns[0] === "imSpotShock") return [IM_SHOCK, MM_SHOCK, 6, 6]; if (fns[0] === "balanceOf") { - return [ - opts.balance, - { netQuantity: 0n, aggregatedEntryPrice: 0n }, - 0n, - 0n, - 0n, - [EXPIRY], - ]; + return snapshotMulticall(opts.balance, [EXPIRY]); } if (fns[0] === "getUserPosition") { - return contracts.map(() => ({ - netQuantity: opts.netQuantity, - netEntryValue: opts.netEntryValue, - })); + // 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 }, + ); } throw new Error(`unexpected multicall head: ${fns[0]}`); }, @@ -117,23 +143,18 @@ describe("futures venue: reduceToTarget", () => { publicClient: { readContract: async (call: ReadCall) => { if (call.functionName === "getMarketPrice") return 100_000n; + if (call.functionName === "collateralToken") return USDC; throw new Error(`unexpected readContract: ${call.functionName}`); }, multicall: async ({ contracts }: { contracts: readonly ReadCall[] }) => { const fns = contracts.map((c) => c.functionName); if (fns[0] === "imSpotShock") return [IM_SHOCK, MM_SHOCK, 6, 6]; if (fns[0] === "balanceOf") { - return [ - 1_000_000n, - { netQuantity: 0n, aggregatedEntryPrice: 0n }, - 0n, - 0n, - 0n, - [EXPIRY, EXPIRY_B, EXPIRY + 172_800n], - ]; + return snapshotMulticall(1_000_000n, [EXPIRY, EXPIRY_B, EXPIRY + 172_800n]); } if (fns[0] === "getUserPosition") { return contracts.map((c) => { + if (c.functionName === "settlementPrice") return 0n; const expirationAt = c.args?.[1] as bigint; return { netQuantity: 4n, diff --git a/portfolio-margin/README.md b/portfolio-margin/README.md new file mode 100644 index 0000000..e5b589c --- /dev/null +++ b/portfolio-margin/README.md @@ -0,0 +1,61 @@ +# @hashpower/portfolio-margin + +Off-chain replica of `PortfolioMarginEngine._computeMargin`, plus the price-threshold +solvers built on top of it. + +## Why this package exists + +Two clients need the same answer to "at what spot price does this account become +liquidatable?": the keeper, which acts on it, and the trading UI, which shows it to the +user. While each kept a private copy of the math they drifted apart — at one point they +clamped unrealized PnL differently, so they genuinely disagreed about who was +liquidatable. One implementation makes that class of bug impossible. + +The package is pure: no dependencies, no side effects, no I/O. It is bigint arithmetic +over a plain snapshot struct. Reading that snapshot from chain is deliberately left to +the caller, because the keeper (batched RPC) and the UI (wagmi hooks) do it very +differently. + +## The model + +The engine's stress model is a four-scenario (±spot, ±vol) grid. These portfolios are +pure delta, so gamma and vega drop out and the worst case is the spot move opposing net +delta. Resting orders are stressed as part of net delta — the worse of the buy-side and +sell-side fills — rather than charged as a flat add-on: + +``` +margin(P) = max( stress(netDelta + buyOrderDelta), + stress(netDelta - sellOrderDelta) ) + + fillLoss(P) + + unrealizedLoss(P) + + fundingOwed +``` + +`margin(P)` is piecewise-linear in `P` with kinks at each break-even, so +`balance - margin(P)` is a tent: an account can have a threshold below spot, above it, +both, or neither. The solvers enumerate kinks and bisect within each monotone interval +rather than solving a closed form. + +IM and MM are not the same function with a different shock. IM clamps unrealized PnL per +market, ignoring gains entirely; MM clamps the portfolio-wide sum, letting a gain at one +venue offset a loss at another. They therefore have different kink sets. See the notes in +`src/mm.ts` and `src/solve.ts`. + +## Usage + +```ts +import { mmRequired, solveLiquidationThresholds } from "@hashpower/portfolio-margin"; + +const required = mmRequired(snapshot, params, markPrice); +const { liqDown, liqUp } = solveLiquidationThresholds(snapshot, params, markPrice); +``` + +## Consumers + +- `collateral-margin/keeper` — depends on it by relative path. +- `futures-marketplace/ui` — depends on it by git URL against this repository. + +The package ships TypeScript sources with no build step: `exports` points straight at +`src/index.ts`. Both consumers already compile TypeScript from their own toolchain (Node +type stripping in the keeper, esbuild in the UI), so there is no `dist/` to rebuild and +nothing to publish or keep in sync — a git ref is the whole release process. diff --git a/portfolio-margin/package.json b/portfolio-margin/package.json new file mode 100644 index 0000000..1023d45 --- /dev/null +++ b/portfolio-margin/package.json @@ -0,0 +1,35 @@ +{ + "name": "@hashpower/portfolio-margin", + "version": "0.1.0", + "description": "Off-chain replica of PortfolioMarginEngine margin math and liquidation-price solvers, shared by the keeper and the trading UI", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/Lumerin-protocol/collateral-margin.git", + "directory": "portfolio-margin" + }, + "keywords": ["hashpower", "portfolio-margin", "liquidation", "margin", "derivatives"], + "files": ["src", "README.md"], + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "sideEffects": false, + "scripts": { + "node": "node --import=amaro/strip", + "test": "pnpm node --test --test-concurrency=1 'tests/*.test.ts'", + "typecheck": "tsc --noEmit", + "lint": "biome lint ." + }, + "devDependencies": { + "@biomejs/biome": "2.4.13", + "amaro": "^1.1.9", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@11.1.1" +} diff --git a/portfolio-margin/pnpm-lock.yaml b/portfolio-margin/pnpm-lock.yaml new file mode 100644 index 0000000..abb436c --- /dev/null +++ b/portfolio-margin/pnpm-lock.yaml @@ -0,0 +1,128 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: 2.4.13 + version: 2.4.13 + amaro: + specifier: ^1.1.9 + version: 1.1.11 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + amaro@1.1.11: + resolution: {integrity: sha512-Tg8KzTpeCUQ23RNSuA2GeVRxHhkHyq8U9jNpKRjiMQKY2C9iJ7pLlGw2iRy4nNMYtB+tkLghk3swCo13O68zDg==} + engines: {node: '>=22'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + + amaro@1.1.11: {} + + typescript@5.9.3: {} diff --git a/portfolio-margin/src/index.ts b/portfolio-margin/src/index.ts new file mode 100644 index 0000000..031c264 --- /dev/null +++ b/portfolio-margin/src/index.ts @@ -0,0 +1,52 @@ +/** + * `@hashpower/portfolio-margin` — an off-chain replica of + * `PortfolioMarginEngine._computeMargin` and the price-threshold solvers built + * on top of it. + * + * This exists because the keeper and the UI both have to answer "at what spot + * price does this account become liquidatable?", and they have to answer it + * identically. When each kept its own copy they drifted — the two clamped + * unrealized PnL differently, so they disagreed about who was liquidatable. + * Keeping one implementation here makes that class of divergence impossible. + * + * The package is deliberately dependency-free and side-effect-free: pure + * bigint arithmetic over a plain snapshot struct. Reading that snapshot from + * chain is the caller's job, because the keeper and the UI do it very + * differently (batched RPC vs wagmi hooks). + */ + +export type { + AccountSnapshot, + Address, + AlertThresholds, + FuturesCloseLeg, + MarginRequirement, + MMParams, + PriceThresholds, + RestingOrders, +} from "./types.ts"; + +export { + fillLoss, + futuresUnrealizedPnl, + imRequired, + imSurplus, + mmRequired, + mmSurplus, + netDeltaWad, + orderDeltaWad, + perpUnrealizedPnl, + stressLoss, + unrealizedLoss, + venueFillLoss, + worstLegStressLoss, +} from "./mm.ts"; + +export { + simulateFuturesClose, + simulatePerpClose, + solveAlertThresholds, + solveFuturesClosesToTarget, + solveLiquidationThresholds, + solvePerpCloseToTarget, +} from "./solve.ts"; diff --git a/portfolio-margin/src/mm.ts b/portfolio-margin/src/mm.ts new file mode 100644 index 0000000..c7843ba --- /dev/null +++ b/portfolio-margin/src/mm.ts @@ -0,0 +1,284 @@ +import type { AccountSnapshot, MarginRequirement, MMParams, RestingOrders } from "./types.ts"; + +/** + * Off-chain replica of `PortfolioMarginEngine._computeMargin`, restricted to + * the pure-delta case (no options Greeks). The contract's stress engine is: + * + * netDelta = perpDelta + futuresDelta + * = (perpNetQty * WAD / 10^perpQtyDecimals) + getNetPositionDelta() + * + * stressLossWad = max over 4 (±spotShock, ±volShock) scenarios of + * max(0, -(netDelta * deltaS / WAD + ½γ(deltaS)² + ν * deltaVol)) + * + * For our pure-delta portfolios (γ=ν=0), the worst scenario is the one where + * `deltaS` opposes `netDelta`, giving `|netDelta| * spotShock * P / WAD²` + * in WAD. We then rescale to token decimals exactly the way `_fromWad` does. + * + * Resting orders are not a separate margin term any more. The engine runs that + * stress twice — once at `netDelta + buyOrderDelta`, once at + * `netDelta − sellOrderDelta` — and keeps the worse leg, which upper-bounds the + * requirement after any subset of the account's orders fills. So the stress term + * here is `max(|netDelta + buyDelta|, |netDelta − sellDelta|) × shock × P`, and the + * add-ons are: + * + * - fillLoss(P) = max(0, buyValue − buyMark(P)) + max(0, sellMark(P) − sellValue), + * charged in both legs + * - the unrealized-PnL term, the one place the two requirements differ in + * *shape* rather than just in shock: + * + * IM: max(0, −perpPnl(P)) + max(0, −futuresPnl(P)) + * MM: max(0, −(perpPnl(P) + futuresPnl(P))) + * + * with one signed PnL per *venue* — `futuresPnl` already netted across every + * expiry, because that is the single number `Futures.getRiskView` hands the + * engine. IM clamps the two venues separately and so ignores gains entirely; + * MM clamps their sum once, so a gain at one venue offsets a loss at the other. + * - perp.fundingOwed (constant — short-term, refreshed on snapshot) + * + * Every term is piecewise-linear in P. The unrealized-PnL kinks differ per + * requirement: on the IM path, one venue-aggregate breakeven each (the perp's + * entry price, and the futures venue's netted breakeven across all expiries); on + * the MM path a single portfolio-wide breakeven, which is generally not any leg's + * entry price. Two further families come from the order terms: each side's + * aggregate fill-loss breakeven (`value / delta`, one per side per venue) and each + * stress leg's own delta zero, where `|netDelta ± orderDelta|` turns around. + * `solve.ts` enumerates all of them. We deliberately keep the math straight (no + * over-engineered piecewise representation) — `mmRequired` is cheap, the solver + * bisects within a kink interval, and the closed-form solver invokes this to + * verify its candidate roots. + * + * All bigint arithmetic. Token-decimal rounding matches PME's integer division. + */ + +const WAD = 10n ** 18n; + +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** + * Net delta of *positions only*, in WAD (matches `_linearAggregate`'s + * `netPositionDelta` sum for pure-delta portfolios). + * + * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals + * futuresDelta = sum_i netQuantity_i * WAD over *unsettled* expiries only + * + * Settled-but-unswept expiries are skipped, matching `Futures.getRiskView`, which + * folds an expiry into `netPositionDelta` only while `settlementPrice` is still + * zero. Once the price is pinned the leg cannot move with spot, so stressing it + * would charge for risk that no longer exists. + * + * Note: the on-chain `getNetPositionDelta` already returns this sum for the + * futures leg; we re-derive it here off-chain because the snapshot carries + * per-expiry aggregates (needed by the per-expiry close solver) and re-using them + * avoids a second contract call. Both paths converge on the same value. + */ +export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + let delta = (snap.perp.netQty * WAD) / perpQtyScale; + for (const pos of snap.futures.positions) { + if (pos.settlementPrice !== 0n) continue; + delta += pos.netQuantity * WAD; + } + return delta; +} + +/** + * Order delta per side summed across venues, lifted to WAD. Venues report these + * scaled by `10^tokenDecimals`, the same convention as `netPositionDelta`. + */ +export function orderDeltaWad(snap: AccountSnapshot, params: MMParams): { + buy: bigint; + sell: bigint; +} { + const lift = 10n ** BigInt(18 - params.tokenDecimals); + return { + buy: (snap.perp.orders.buyDelta + snap.futures.orders.buyDelta) * lift, + sell: (snap.perp.orders.sellDelta + snap.futures.orders.sellDelta) * lift, + }; +} + +/** + * Scale a WAD-denominated value down to token decimals using PME's exact + * convention (integer division by `10^(18 - tokenDecimals)`). + */ +function fromWad(wadValue: bigint, tokenDecimals: number): bigint { + return wadValue / 10n ** BigInt(18 - tokenDecimals); +} + +/** + * Stress loss in token decimals. Pure-delta worst case: + * + * |delta| * shock * P_wad / WAD² (in WAD) + * + * where P_wad = P_token * 10^(18 - tokenDecimals). + * + * Equivalent to the on-chain 4-scenario max in the absence of γ and ν. + */ +export function stressLoss( + delta: bigint, + shock: bigint, + P: bigint, + tokenDecimals: number, +): bigint { + const Pwad = P * 10n ** BigInt(18 - tokenDecimals); + const stressWad = (abs(delta) * shock * Pwad) / (WAD * WAD); + return fromWad(stressWad, tokenDecimals); +} + +/** + * Worse of the two fill legs, in token decimals: the engine stresses + * `netDelta + buyDelta` and `netDelta − sellDelta` and takes the maximum. + */ +export function worstLegStressLoss( + snap: AccountSnapshot, + params: MMParams, + shock: bigint, + P: bigint, +): bigint { + const netDelta = netDeltaWad(snap, params); + const order = orderDeltaWad(snap, params); + const buyLeg = stressLoss(netDelta + order.buy, shock, P, params.tokenDecimals); + const sellLeg = stressLoss(netDelta - order.sell, shock, P, params.tokenDecimals); + return buyLeg > sellLeg ? buyLeg : sellLeg; +} + +/** + * Instant mark-to-market loss if a whole side of a venue's book filled at price P. + * Clamped per side across the venue's book, matching both venues' `getRiskView`. + * + * buy: max(0, buyValue − P × buyDelta / 10^tokenDecimals) + * sell: max(0, P × sellDelta / 10^tokenDecimals − sellValue) + */ +export function venueFillLoss(orders: RestingOrders, P: bigint, tokenDecimals: number): bigint { + const scale = 10n ** BigInt(tokenDecimals); + let loss = 0n; + const buyMark = (P * orders.buyDelta) / scale; + if (orders.buyValue > buyMark) loss += orders.buyValue - buyMark; + const sellMark = (P * orders.sellDelta) / scale; + if (sellMark > orders.sellValue) loss += sellMark - orders.sellValue; + return loss; +} + +/** Both venues' fill loss at P. The engine charges this in both stress legs. */ +export function fillLoss(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return ( + venueFillLoss(snap.perp.orders, P, params.tokenDecimals) + + venueFillLoss(snap.futures.orders, P, params.tokenDecimals) + ); +} + +/** + * Signed perp unrealized PnL at price P (token decimals): + * + * pnl = (P - entry) * netQty / 10^perpQtyDecimals + * + * Signed and unclamped on purpose: the clamp belongs to the requirement, not the + * venue, and where it lands differs between IM and MM. See `unrealizedLoss`. + */ +export function perpUnrealizedPnl(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + if (snap.perp.netQty === 0n) return 0n; + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + return ((P - snap.perp.entryPrice) * snap.perp.netQty) / perpQtyScale; +} + +/** + * Signed futures unrealized PnL at price P (token decimals), netted across every + * active expiry. Each whole contract settles `pricePerDay` of notional (no + * duration multiplier): + * + * pnl = sum_i (mark_i * netQuantity_i - netEntryValue_i) + * + * `mark_i` is the expiry's pinned `settlementPrice` when it has one and `P` + * otherwise, mirroring `getRiskView`'s per-expiry choice. A settled leg's PnL is + * therefore constant in P: it is a realized amount awaiting a sweep, not an + * exposure, and revaluing it at a hypothetical price would invent PnL the account + * can no longer gain or lose. + * + * The netting is not an off-chain approximation — it is what the engine sees. + * `Futures.getRiskView` accumulates one signed `totalPnl` over the participant's + * active expiries and reports that as the market's `unrealizedPnl`; individual + * expiries never reach the PME. Clamping per expiry (which this module used to do) + * over-charges every calendar spread, on both the IM and the MM path. + */ +export function futuresUnrealizedPnl(snap: AccountSnapshot, P: bigint): bigint { + let pnl = 0n; + for (const pos of snap.futures.positions) { + const mark = pos.settlementPrice !== 0n ? pos.settlementPrice : P; + pnl += mark * pos.netQuantity - pos.netEntryValue; + } + return pnl; +} + +/** + * The engine's `pnlTerm` at price P (token decimals): + * + * IM: max(0, -perpPnl) + max(0, -futuresPnl) clamped per market, gains ignored + * MM: max(0, -(perpPnl + futuresPnl)) clamped once, gains offset losses + * + * Mirrors `_linearAggregate`'s `unrealizedLossPerMarket` / `netUnrealizedPnl` pair + * and the `isIM` pick in `_marginFromAggregate`. "Per market" means per venue: + * there are exactly two registered linear markets, and the futures leg arrives at + * the engine already netted across its expiries, so the IM path clamps two + * numbers — never one per expiry. + */ +export function unrealizedLoss( + snap: AccountSnapshot, + params: MMParams, + P: bigint, + requirement: MarginRequirement, +): bigint { + const perp = perpUnrealizedPnl(snap, params, P); + const futures = futuresUnrealizedPnl(snap, P); + if (requirement === "mm") { + const total = perp + futures; + return total < 0n ? -total : 0n; + } + return (perp < 0n ? -perp : 0n) + (futures < 0n ? -futures : 0n); +} + +/** + * Shared body of `mmRequired` / `imRequired`. The requirement selects both the + * shock and the unrealized-PnL clamp; taking one argument rather than two keeps + * the pair from ever disagreeing. + */ +function requiredAt( + snap: AccountSnapshot, + params: MMParams, + P: bigint, + requirement: MarginRequirement, +): bigint { + const shock = requirement === "im" ? params.imSpotShock : params.mmSpotShock; + return ( + worstLegStressLoss(snap, params, shock, P) + + fillLoss(snap, params, P) + + unrealizedLoss(snap, params, P, requirement) + + snap.perp.fundingOwed + ); +} + +/** + * Maintenance-margin requirement at price P. Mirrors PME's + * `_computeMargin(user, isIM=false)` for pure-delta portfolios. + */ +export function mmRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return requiredAt(snap, params, P, "mm"); +} + +/** + * Initial-margin requirement at price P. Same shape, with the IM shock and the + * per-market PnL clamp. + */ +export function imRequired(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return requiredAt(snap, params, P, "im"); +} + +/** `balance - mmRequired(P)`. Negative = liquidatable. */ +export function mmSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return snap.balance - mmRequired(snap, params, P); +} + +/** `balance - imRequired(P)`. Negative = below IM (warn / critical band). */ +export function imSurplus(snap: AccountSnapshot, params: MMParams, P: bigint): bigint { + return snap.balance - imRequired(snap, params, P); +} diff --git a/portfolio-margin/src/solve.ts b/portfolio-margin/src/solve.ts new file mode 100644 index 0000000..2da47a0 --- /dev/null +++ b/portfolio-margin/src/solve.ts @@ -0,0 +1,793 @@ +import type { + AccountSnapshot, + AlertThresholds, + FuturesCloseLeg, + MarginRequirement, + MMParams, + PriceThresholds, + RestingOrders, +} from "./types.ts"; +import { + futuresUnrealizedPnl, + imRequired, + imSurplus, + mmRequired, + mmSurplus, + netDeltaWad, + orderDeltaWad, +} from "./mm.ts"; + +const WAD = 10n ** 18n; + +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** Floor division (bigint `/` truncates toward zero, which is wrong below zero). */ +function floorDiv(a: bigint, b: bigint): bigint { + const q = a / b; + return a % b !== 0n && a < 0n !== b < 0n ? q - 1n : q; +} + +/** + * A rational root as the pair of integers straddling it. Truncating division can + * never land on the exact breakeven, so both sides are emitted and whichever is + * the real turning point becomes an interval boundary; the other is a spare. + */ +function straddle(root: bigint): bigint[] { + return [root, root + 1n]; +} + +/** Average entry price for an aggregate (`|netEntryValue| / |netQuantity|`). */ +function avgEntry(pos: AccountSnapshot["futures"]["positions"][number]): bigint { + const absNet = abs(pos.netQuantity); + if (absNet === 0n) return 0n; + return abs(pos.netEntryValue) / absNet; +} + +/** + * The two prices at which a venue's per-side fill loss reaches zero: the aggregate + * breakeven `value / delta`, one per side. Below its breakeven a bid side carries a + * loss, above it none; the ask side is the mirror. There is exactly one kink per + * side per venue no matter how many orders rest, because both venues clamp the loss + * per side across the whole book rather than per order. + */ +function fillLossBreakevens(orders: RestingOrders, tokenDecimals: number): bigint[] { + const scale = 10n ** BigInt(tokenDecimals); + const kinks: bigint[] = []; + if (orders.buyDelta > 0n) kinks.push((orders.buyValue * scale) / orders.buyDelta); + if (orders.sellDelta > 0n) kinks.push((orders.sellValue * scale) / orders.sellDelta); + return kinks; +} + +/** + * The futures venue's PnL reduced to the affine form `P · qty − value`, which is + * what every breakeven below is solved against. + * + * A settled-but-unswept expiry is pinned at its `settlementPrice`, so it drops out + * of `qty` — it no longer moves with spot — and folds its frozen mark into `value` + * as a constant. Leaving it in `qty` would put the breakeven at a price that does + * not exist, since the leg cannot reach it. + */ +function futuresPnlTerms(snap: AccountSnapshot): { qty: bigint; value: bigint } { + let qty = 0n; + let value = 0n; + for (const pos of snap.futures.positions) { + value += pos.netEntryValue; + if (pos.settlementPrice !== 0n) { + value -= pos.settlementPrice * pos.netQuantity; + continue; + } + qty += pos.netQuantity; + } + return { qty, value }; +} + +/** + * Prices at which the requirement's unrealized-PnL term changes slope. The set + * depends on which requirement is being solved, because the two clamp differently. + * + * IM clamps per market, so each venue contributes its own breakeven: the perp's + * entry price, and the futures venue's *aggregate* breakeven across every expiry, + * `Σ netEntryValue / Σ netQuantity`. One kink for the whole futures venue, not one + * per expiry — `Futures.getRiskView` nets the expiries into a single signed number + * before the engine clamps it. A calendar spread whose quantities cancel + * (`Σ netQuantity == 0`) has a PnL constant in P and no kink at all. + * + * MM clamps the portfolio-wide sum, and that sum is a *single* affine function of + * P, so there is exactly one kink: the price where the perp and futures PnL cancel. + * It is generally not any leg's entry price — a perp long entered at $100 netted + * against a futures short entered at $50 breaks even at neither. When the two + * venues' price coefficients cancel exactly the aggregate is constant in P, and + * again there is no kink. + * + * The perp leg divides by its quantity scale, so its PnL is a staircase rather + * than a true line and the clamp can flip a step away from the rational root + * emitted here. That displacement is bounded by one token-decimal unit of margin — + * the same rounding slop the requirement already carries from `fromWad` — so it + * cannot hide a crossing of any size. + */ +function unrealizedPnlBreakevens( + snap: AccountSnapshot, + params: MMParams, + requirement: MarginRequirement, +): bigint[] { + const netQty = snap.perp.netQty; + const { qty: futuresQty, value: futuresValue } = futuresPnlTerms(snap); + + if (requirement === "im") { + const kinks: bigint[] = []; + if (netQty !== 0n) kinks.push(snap.perp.entryPrice); + if (futuresQty !== 0n) kinks.push(...straddle(floorDiv(futuresValue, futuresQty))); + return kinks.filter((k) => k > 0n); + } + + // perpPnl(P) + futuresPnl(P) == 0 + // ⇔ (P − entry)·netQty / scale + P·Σq − Σv == 0 + // ⇔ P·(netQty + scale·Σq) == entry·netQty + scale·Σv + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + const coefficient = netQty + perpQtyScale * futuresQty; + if (coefficient === 0n) return []; + const intercept = snap.perp.entryPrice * netQty + perpQtyScale * futuresValue; + return straddle(floorDiv(intercept, coefficient)).filter((k) => k > 0n); +} + +/** + * Find the price thresholds where `mmSurplus(P)` crosses zero. + * + * `mmRequired(P)` is piecewise-linear in P with kinks at the portfolio-wide + * unrealized-PnL break-even (one, because MM clamps the venues' signed sum once) + * and at each venue's per-side fill-loss breakeven. Stress is + * `max(|netDelta + buyDelta|, |netDelta − sellDelta|) × shock × P / WAD` after + * rescaling — strictly non-decreasing in P, and with no kink of its own, because + * net delta and order delta are both independent of price: the two legs are lines + * through the origin, so whichever has the larger coefficient wins at every price. + * + * For a typical net-long portfolio, `mmSurplus(P)` is therefore a tent shape: + * - Climbs as P rises (PnL recovers faster than stress grows) until the + * portfolio's aggregate PnL breaks even. + * - Above that price, only stress contributes — `mmSurplus(P)` declines + * linearly to negative infinity as P → ∞. + * Net-short portfolios mirror this around an inverted apex. + * + * We don't try to derive a single closed form for the general piecewise + * landscape — between leg counts, sign mixes, and stress magnitude vs. + * leverage, the case analysis is fragile. Instead we: + * + * 1. Enumerate the kink prices (the MM aggregate PnL breakeven, both venues' + * per-side fill-loss breakevens). + * 2. Bisect on each side of the current price (down and up) on intervals + * bounded by adjacent kinks. `mmSurplus(P)` is monotone within each + * interval, so a standard bisection converges in O(log) per interval. + * 3. Return the closest crossings on either side of `currentPrice`. + * + * O(K · log(2^60)) per user where K is the number of kinks (≤ 7 — the PnL + * breakeven no longer scales with the number of futures expiries, since the venue + * nets them into one). At keeper scale this is a handful of µs of pure CPU work — + * negligible vs the RPC the snapshot read already cost. + */ +export function solveLiquidationThresholds( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, +): PriceThresholds { + // Already underwater → no useful threshold; the caller should liquidate + // immediately rather than wait for a future price tick. + if (mmSurplus(snap, params, currentPrice) < 0n) { + return { user: snap.user, liqDown: undefined, liqUp: undefined }; + } + const result = findClosestCrossings( + snap, + params, + currentPrice, + (P) => mmSurplus(snap, params, P), + "mm", + ); + return { user: snap.user, liqDown: result.down, liqUp: result.up }; +} + +/** + * Find the prices at which the user's IM utilization (`imRequired / balance`) + * crosses the warn and critical thresholds. Used by the predictive + * coordinator to fire alerts *before* the next sweep tick discovers them. + * + * For each level we solve `imRequired(P) - level * balance = 0`. Returns + * `undefined` for any side that's never crossed (e.g. a flat user can't be + * pushed into IM-warn by price moves). Already past the threshold at + * `currentPrice` → returns `undefined` for that level (the sweep-driven + * alert path will catch it on the next tick). + */ +export function solveAlertThresholds( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + warnUtilizationPpm: bigint, + criticalUtilizationPpm: bigint, +): AlertThresholds { + // No collateral → no IM utilization is well-defined; sweep handles it. + if (snap.balance <= 0n) { + return { + user: snap.user, + warnDown: undefined, + warnUp: undefined, + critDown: undefined, + critUp: undefined, + }; + } + // Target ppm scaling: imRequired - util * balance = imRequired - (utilPpm * balance) / 1e6 + const PPM = 1_000_000n; + const warnTarget = (warnUtilizationPpm * snap.balance) / PPM; + const critTarget = (criticalUtilizationPpm * snap.balance) / PPM; + const f = (target: bigint) => (P: bigint) => imRequired(snap, params, P) - target; + + // For an alert level we want price points where `imRequired(P) = target`. + // Already at-or-over the target at currentPrice → that level isn't a + // forward-looking trigger; the sweep alert path will fire it. + const warn = + imRequired(snap, params, currentPrice) >= warnTarget + ? { down: undefined, up: undefined } + : findClosestCrossings(snap, params, currentPrice, f(warnTarget), "im"); + const crit = + imRequired(snap, params, currentPrice) >= critTarget + ? { down: undefined, up: undefined } + : findClosestCrossings(snap, params, currentPrice, f(critTarget), "im"); + return { + user: snap.user, + warnDown: warn.down, + warnUp: warn.up, + critDown: crit.down, + critUp: crit.up, + }; +} + +/** + * Generic: find the closest prices on either side of `currentPrice` where + * the supplied `f` function crosses zero. Uses the same kink-driven + * piecewise-monotone bisection as `solveLiquidationThresholds`, parameterised + * so multiple solvers (liq, im-warn, im-crit) can share the engine. + * + * Sign-convention agnostic: detects crossings regardless of which sign + * means "safe". Callers are responsible for short-circuiting when + * currentPrice is already past the threshold of interest. + * + * `requirement` must name the requirement `f` is built on. It is not cosmetic: + * IM and MM clamp unrealized PnL at different places, so they kink at different + * prices, and an interval boundary set for the wrong one leaves a non-monotone + * interval that bisection can walk straight past. + */ +function findClosestCrossings( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + f: (P: bigint) => bigint, + requirement: MarginRequirement, +): { down: bigint | undefined; up: bigint | undefined } { + const kinks: bigint[] = []; + kinks.push(...unrealizedPnlBreakevens(snap, params, requirement)); + kinks.push(...fillLossBreakevens(snap.perp.orders, params.tokenDecimals)); + kinks.push(...fillLossBreakevens(snap.futures.orders, params.tokenDecimals)); + kinks.push(currentPrice); + kinks.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const dedup: bigint[] = []; + for (const k of kinks) { + if (dedup.length === 0 || dedup[dedup.length - 1] !== k) dedup.push(k); + } + + const lastDedup = dedup[dedup.length - 1] ?? currentPrice; + const upperCap = lastDedup * 1024n + 1n; + const lowerCap = 1n; + + const intervals: Array<[bigint, bigint]> = []; + let prev = lowerCap; + for (const k of dedup) { + if (k > prev) intervals.push([prev, k]); + prev = k; + } + if (upperCap > prev) intervals.push([prev, upperCap]); + + let down: bigint | undefined; + let up: bigint | undefined; + + for (const [lo, hi] of intervals) { + const sLo = f(lo); + const sHi = f(hi); + if ((sLo > 0n && sHi > 0n) || (sLo < 0n && sHi < 0n)) continue; + if (sLo === 0n) { + registerCrossing(lo, currentPrice, (isDown) => { + if (isDown) down = closer(down, lo, currentPrice, true); + else up = closer(up, lo, currentPrice, false); + }); + continue; + } + if (sHi === 0n) { + registerCrossing(hi, currentPrice, (isDown) => { + if (isDown) down = closer(down, hi, currentPrice, true); + else up = closer(up, hi, currentPrice, false); + }); + continue; + } + const root = bisect(lo, hi, sLo, f); + if (root < currentPrice) down = closer(down, root, currentPrice, true); + else if (root > currentPrice) up = closer(up, root, currentPrice, false); + } + + return { down, up }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Close-to-IM-buffer sizing (the batched-liquidation solvers) +// +// The on-chain `liquidatePositions` (futures) / `liquidatePosition(user, +// closeQty)` (perps) treat the keeper-supplied amount as an upper bound and +// revert `OverLiquidation` when a partial leaves balance above IM with a real +// IM buffer (`im > mm`). These solvers pick, off-chain, the deepest close that +// keeps the account inside the `[MM, IM]` band (healthy but not +// over-liquidated). If no in-band partial exists (deep crash / bad debt) they +// fall back to a full close, which the contract lets through (the guard is +// skipped once no positions remain). +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Off-chain replica of the futures batch close: reduce each aggregate toward + * zero by `closeQty` and debit realized PnL + flat fee per expiry leg. + * Mirrors `Futures._doPartialLiquidatePosition` / `_doLiquidateFullPosition`. + */ +export function simulateFuturesClose( + snap: AccountSnapshot, + closes: readonly FuturesCloseLeg[], + currentPrice: bigint, + liquidationFee: bigint, +): AccountSnapshot { + const closeByExpiry = new Map(); + for (const c of closes) { + closeByExpiry.set(c.expirationAt, (closeByExpiry.get(c.expirationAt) ?? 0n) + c.closeQty); + } + + const remaining: AccountSnapshot["futures"]["positions"] = []; + let balanceDelta = 0n; + for (const pos of snap.futures.positions) { + const want = closeByExpiry.get(pos.expirationAt) ?? 0n; + if (want <= 0n) { + remaining.push(pos); + continue; + } + const absNet = abs(pos.netQuantity); + const closeAbs = want < absNet ? want : absNet; + if (closeAbs <= 0n) { + remaining.push(pos); + continue; + } + + const entry = avgEntry(pos); + const signedClose = pos.netQuantity > 0n ? closeAbs : -closeAbs; + // A settled leg realizes against its pinned price, not spot — that is the + // mark it has been carrying since settlement, and it cannot move again. + const mark = pos.settlementPrice !== 0n ? pos.settlementPrice : currentPrice; + const pnl = (mark - entry) * signedClose; + balanceDelta += pnl - liquidationFee; + + if (closeAbs >= absNet) continue; + const newAbs = absNet - closeAbs; + remaining.push({ + expirationAt: pos.expirationAt, + netQuantity: pos.netQuantity > 0n ? newAbs : -newAbs, + netEntryValue: (pos.netEntryValue * newAbs) / absNet, + settlementPrice: pos.settlementPrice, + }); + } + + return { + ...snap, + balance: snap.balance + balanceDelta, + futures: { ...snap.futures, positions: remaining }, + }; +} + +/** + * Off-chain replica of the perps partial close: reduce `netQty` toward zero by + * `min(closeQty, |netQty|)` and debit the realized PnL on that slice plus the + * single flat fee. Mirrors `HashPowerPerpsDEX._doPartialLiquidatePosition` + * (`_settleReducedPosition` + one `liquidationFee`). Entry price unchanged. + */ +export function simulatePerpClose( + snap: AccountSnapshot, + closeQty: bigint, + currentPrice: bigint, + liquidationFee: bigint, +): AccountSnapshot { + const netQty = snap.perp.netQty; + const absNet = netQty < 0n ? -netQty : netQty; + const closeAbs = closeQty < absNet ? closeQty : absNet; + if (closeAbs <= 0n) return snap; + + const isLong = netQty > 0n; + const signedClose = isLong ? closeAbs : -closeAbs; + // Perps quantities are scaled by 10^QUANTITY_DECIMALS (=6 in HashPowerPerpsDEX); + // matches `perpUnrealizedPnl` in mm.ts and the venue's QUANTITY_SCALE. + const qtyScale = 10n ** 6n; + const pnl = ((currentPrice - snap.perp.entryPrice) * signedClose) / qtyScale; + const newNetQty = isLong ? netQty - closeAbs : netQty + closeAbs; + return { + ...snap, + balance: snap.balance + pnl - liquidationFee, + perp: { ...snap.perp, netQty: newNetQty }, + }; +} + +/** + * Pick per-expiry `closeQty` legs so the account lands inside the `[MM, IM]` + * band. Unit closes are ranked by their effect on the requirement (see + * `rankUnitClosesBalancedAcrossExpirations`) and interleaved across expiries + * (round-robin) so a prefix does not drain one book before touching another. + * Returns `[]` if already healthy, or a full close of every aggregate when no + * in-band partial exists (deep crash / bad debt). + */ +export function solveFuturesClosesToTarget( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + liquidationFee: bigint, +): FuturesCloseLeg[] { + const positions = snap.futures.positions; + if (positions.length === 0) return []; + if (mmSurplus(snap, params, currentPrice) >= 0n) return []; + + const hasBuffer = params.imSpotShock > params.mmSpotShock; + const unitSequence = rankUnitClosesBalancedAcrossExpirations( + snap, + params, + currentPrice, + liquidationFee, + ); + const n = unitSequence.length; + if (n === 0) return []; + + let bestPrefix = 0; + let foundInBand = false; + for (let k = 1; k <= n; k++) { + const closes = coalesceUnitPrefix(unitSequence, k); + const after = simulateFuturesClose(snap, closes, currentPrice, liquidationFee); + const mmS = mmSurplus(after, params, currentPrice); + const imS = imSurplus(after, params, currentPrice); + if (!hasBuffer) { + if (mmS >= 0n) { + bestPrefix = k; + foundInBand = true; + break; + } + continue; + } + if (mmS >= 0n && imS <= 0n) { + bestPrefix = k; + foundInBand = true; + } + // No early exit on `imS > 0`. Closing contracts no longer shrinks the + // requirement monotonically: the engine stresses `netDelta − sellOrderDelta` + // as well, and that leg *grows* as a long position closes toward flat past the + // point where the resting asks outweigh it. So the in-band set is not a + // contiguous prefix and the first prefix over IM is not the last one under it. + // The worst case was already a full scan (an account that never lands in + // band), so this costs nothing asymptotically. + } + + if (!foundInBand) { + // Full close every aggregate. + return positions.map((p) => ({ + expirationAt: p.expirationAt, + closeQty: abs(p.netQuantity), + })); + } + // Emit 1-qty legs in round-robin order (not coalesced/sorted by expiry). + // `liquidatePositions` stops once healthy; coalescing into [A:N, B:M] would + // drain A first and skip B. Interleaved unit legs keep the prefix balanced. + return unitSequence.slice(0, bestPrefix).map((expirationAt) => ({ + expirationAt, + closeQty: 1n, + })); +} + +/** + * Pick the absolute `closeQty` (scaled by perp quantity decimals) to partially + * close a perps position down into the `[MM, IM]` band. With a real IM buffer we + * take the deepest close that stays at/under IM (which is automatically ≥ the + * minimal-healthy amount); degenerate `IM == MM` targets minimal-healthy. Returns + * `0n` if already healthy, or `|netQty|` (full close) when even closing everything + * can't reach the band (deep crash / bad debt). + * + * This used to bisect `[0, |netQty|]` in one shot on the premise that both surpluses + * are monotone increasing in the closed quantity. That premise is gone. The engine + * now stresses `netDelta − sellOrderDelta` alongside `netDelta + buyOrderDelta`, and + * closing a long drives net delta toward zero — which *increases* `|netDelta − sell|` + * once the resting asks outweigh what is left of the position. Perps orders must be + * cleared before `liquidatePosition` (the venue reverts `OrdersStillOpen`), but the + * order delta the engine sees is portfolio-wide, so a user's resting *futures* book + * still feeds these legs while their perp is being closed. + * + * What survives is weaker but enough: the requirement is piecewise-linear in the + * closed quantity, with kinks only where a stress leg's delta crosses zero, where + * the two legs swap places, or where the MM clamp on the portfolio's aggregate PnL + * turns over. `perpCloseKinks` enumerates those points, and we bisect within each + * resulting interval, where linearity restores monotonicity. + */ +export function solvePerpCloseToTarget( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + liquidationFee: bigint, +): bigint { + const netQty = snap.perp.netQty; + const absNet = netQty < 0n ? -netQty : netQty; + if (absNet === 0n) return 0n; + if (mmSurplus(snap, params, currentPrice) >= 0n) return 0n; + + const mmS = (q: bigint) => + mmSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); + const imS = (q: bigint) => + imSurplus(simulatePerpClose(snap, q, currentPrice, liquidationFee), params, currentPrice); + + const hasBuffer = params.imSpotShock > params.mmSpotShock; + const bounds = perpCloseKinks(snap, params, absNet, currentPrice); + + let best: bigint | undefined; + for (let i = 0; i + 1 < bounds.length; i++) { + const healthy = nonNegativeRange(bounds[i], bounds[i + 1], mmS); + if (healthy === undefined) continue; + + if (!hasBuffer) { + // Degenerate IM == MM: the shallowest healthy close is the answer, and the + // intervals are walked in increasing quantity, so the first one wins. + return healthy.lo >= absNet ? absNet : healthy.lo; + } + + const underIM = nonNegativeRange(healthy.lo, healthy.hi, (q) => -imS(q)); + if (underIM === undefined) continue; + if (best === undefined || underIM.hi > best) best = underIM.hi; + } + + if (best === undefined) return absNet; + return best >= absNet ? absNet : best; +} + +/** + * Closed quantities at which the margin requirement stops being linear, so that + * each `[bounds[i], bounds[i+1]]` interval is safe to bisect. Closing changes net + * delta affinely, `netDelta(q) = netDelta(0) − sign(netQty) · q · WAD / qtyScale`, and + * everything else in the requirement is either affine in `q` (both venues' signed + * PnL, the realized PnL credited to the balance) or untouched by the close (fill + * loss, funding). The kinks are therefore the points where one of the requirement's + * two clamps turns over: + * + * - The stress term `max(|netDelta + buy|, |netDelta − sell|)`: the two absolute + * values turn at `netDelta = −buy` and `netDelta = sell`, and the outer `max` + * switches legs where they meet, at `netDelta = (sell − buy) / 2`. + * - The MM clamp on the portfolio-wide unrealized PnL, `max(0, −(perpPnl(q) + F))` + * with `F` the futures venue's PnL (constant in `q` — a perp close does not + * touch it). + * + * That second family is new, and it is the one the per-market clamp let us skip. + * The old argument was that closing moves a position toward zero without crossing + * it, so `perpPnl` keeps its sign and its clamp never turns. That still holds for + * IM, which clamps the perp's PnL on its own — the IM path contributes no kink here. + * It fails for MM, which clamps the *sum*: with futures carrying a constant +$100 + * and the perp −$150, the total is −$50 at `q = 0` and +$100 at a full close, so it + * crosses zero partway through and the clamp turns with it. Solving + * `perpPnl(q) + F = 0` for `q`, where `perpPnl(q) = (P − entry)(netQty − sign·q) / scale`: + * + * q = ((P − entry)·netQty + F·scale) / ((P − entry)·sign) + * + * undefined (and irrelevant) at `P == entry`, where the perp carries no PnL at any + * `q` and the term is the constant `max(0, −F)`. + * + * Each root is emitted as both its floor and floor+1 because integer division + * truncates and the true root lies in between. + */ +function perpCloseKinks( + snap: AccountSnapshot, + params: MMParams, + absNet: bigint, + currentPrice: bigint, +): bigint[] { + const netQty = snap.perp.netQty; + const sign = netQty > 0n ? 1n : -1n; + const perpQtyScale = 10n ** BigInt(params.perpQuantityDecimals); + const perUnit = WAD / perpQtyScale; + const delta0 = netDeltaWad(snap, params); + const order = orderDeltaWad(snap, params); + const qtyAtDelta = (target: bigint) => (sign * (delta0 - target)) / perUnit; + + const roots = [ + qtyAtDelta(-order.buy), + qtyAtDelta(order.sell), + qtyAtDelta((order.sell - order.buy) / 2n), + ]; + + const priceDiff = currentPrice - snap.perp.entryPrice; + if (priceDiff !== 0n) { + const futuresPnl = futuresUnrealizedPnl(snap, currentPrice); + roots.push(floorDiv(priceDiff * netQty + futuresPnl * perpQtyScale, priceDiff * sign)); + } + + const bounds = new Set([0n, absNet]); + for (const root of roots) { + for (const q of straddle(root)) { + if (q > 0n && q < absNet) bounds.add(q); + } + } + return [...bounds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +/** + * The sub-range of `[lo, hi]` on which `f(q) >= 0`, or `undefined` if nowhere. + * Requires `f` to be monotone on `[lo, hi]` — callers get that from + * `perpCloseKinks`, which cuts the domain at every point where linearity breaks. + * The direction is read off the endpoints rather than assumed, so the same + * bisection serves both the increasing MM-surplus and the decreasing IM-surplus + * side of the band. + */ +function nonNegativeRange( + lo: bigint, + hi: bigint, + f: (q: bigint) => bigint, +): { lo: bigint; hi: bigint } | undefined { + const atLo = f(lo); + const atHi = f(hi); + if (atLo >= 0n && atHi >= 0n) return { lo, hi }; + if (atLo < 0n && atHi < 0n) return undefined; + + let a = lo; + let b = hi; + if (atLo < 0n) { + while (b - a > 1n) { + const m = (a + b) / 2n; + if (f(m) >= 0n) b = m; + else a = m; + } + return { lo: b, hi }; + } + while (b - a > 1n) { + const m = (a + b) / 2n; + if (f(m) >= 0n) a = m; + else b = m; + } + return { lo, hi: a }; +} + +type FuturesAggregate = AccountSnapshot["futures"]["positions"][number]; + +/** + * Expand aggregates into a unit-close sequence interleaved across expiries. + * Each unit is one whole contract at a `expirationAt`. Groups (expiries) are + * ordered by how much closing one unit *drops the requirement* (desc); within the + * sequence we round-robin one unit from each group until books are exhausted. + * + * This used to rank by the aggregate's standalone unrealized loss, on the reading + * that the biggest loser frees the most margin. Netting retires that: the engine + * charges MM on the portfolio-wide signed PnL, so an aggregate's own loss says + * nothing about the requirement until you know what the rest of the portfolio does + * with it. A profitable aggregate now ranks *below* a flat one, because closing it + * strips an offset the losing legs were leaning on and the requirement goes up. + * Measuring the drop directly keeps the rationale from rotting again — whatever + * the engine's PnL term does, this ranks by its response. + * + * MM (not IM) because MM is the constraint the close is trying to clear, and it is + * the netted one. Only a heuristic either way: `solveFuturesClosesToTarget` scans + * prefixes against the real requirement, so a mis-ranking costs close depth, never + * correctness. + */ +function rankUnitClosesBalancedAcrossExpirations( + snap: AccountSnapshot, + params: MMParams, + currentPrice: bigint, + liquidationFee: bigint, +): bigint[] { + const positions = snap.futures.positions; + const before = mmRequired(snap, params, currentPrice); + const dropOf = (p: FuturesAggregate) => + before - mmRequired(unitClosed(snap, p, currentPrice, liquidationFee), params, currentPrice); + const drops = new Map(); + for (const p of positions) { + if (p.netQuantity !== 0n) drops.set(p.expirationAt, dropOf(p)); + } + const ordered = [...positions] + .filter((p) => p.netQuantity !== 0n) + .sort((a, b) => { + const da = drops.get(a.expirationAt) ?? 0n; + const db = drops.get(b.expirationAt) ?? 0n; + if (da !== db) return da < db ? 1 : -1; + const na = abs(a.netQuantity) * avgEntry(a); + const nb = abs(b.netQuantity) * avgEntry(b); + if (na !== nb) return na < nb ? 1 : -1; + return a.expirationAt < b.expirationAt ? -1 : a.expirationAt > b.expirationAt ? 1 : 0; + }); + + const remaining = ordered.map((p) => abs(p.netQuantity)); + const result: bigint[] = []; + let progress = true; + while (progress) { + progress = false; + for (let i = 0; i < ordered.length; i++) { + const left = remaining[i] ?? 0n; + if (left <= 0n) continue; + remaining[i] = left - 1n; + result.push(ordered[i]!.expirationAt); + progress = true; + } + } + return result; +} + +function coalesceUnitPrefix(unitSequence: readonly bigint[], prefixLen: number): FuturesCloseLeg[] { + const counts = new Map(); + for (let i = 0; i < prefixLen && i < unitSequence.length; i++) { + const d = unitSequence[i]!; + counts.set(d, (counts.get(d) ?? 0n) + 1n); + } + return [...counts.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([expirationAt, closeQty]) => ({ expirationAt, closeQty })); +} + +/** The snapshot after closing one contract of `pos` at `P`. */ +function unitClosed( + snap: AccountSnapshot, + pos: FuturesAggregate, + P: bigint, + liquidationFee: bigint, +): AccountSnapshot { + return simulateFuturesClose( + snap, + [{ expirationAt: pos.expirationAt, closeQty: 1n }], + P, + liquidationFee, + ); +} + +/** Bisect within [lo, hi] until the interval shrinks to 1 wei. Assumes a sign change. */ +function bisect( + lo: bigint, + hi: bigint, + sLo: bigint, + f: (P: bigint) => bigint, +): bigint { + let a = lo; + let b = hi; + let sa = sLo; + // Conservative iteration cap: for any 256-bit price the interval halves + // 256 times before becoming 1 wei. We never actually reach that — we exit + // on the (b - a) <= 1 condition first. + for (let i = 0; i < 256; i++) { + if (b - a <= 1n) return sa < 0n ? b : a; + const mid = (a + b) / 2n; + const sm = f(mid); + if (sm === 0n) return mid; + // Maintain invariant: sa and sb have opposite signs. + if ((sa < 0n && sm < 0n) || (sa > 0n && sm > 0n)) { + a = mid; + sa = sm; + } else { + b = mid; + } + } + return a; +} + +function registerCrossing(at: bigint, currentPrice: bigint, sink: (down: boolean) => void): void { + if (at < currentPrice) sink(true); + else if (at > currentPrice) sink(false); +} + +/** + * Pick whichever candidate threshold is *closer* to `currentPrice`. For the + * downside ("liquidatable when spot falls below"), closer means the one + * with the higher price; for the upside, the one with the lower price. + */ +function closer( + prev: bigint | undefined, + candidate: bigint, + _currentPrice: bigint, + isDown: boolean, +): bigint { + if (prev === undefined) return candidate; + if (isDown) return candidate > prev ? candidate : prev; + return candidate < prev ? candidate : prev; +} diff --git a/portfolio-margin/src/types.ts b/portfolio-margin/src/types.ts new file mode 100644 index 0000000..472e406 --- /dev/null +++ b/portfolio-margin/src/types.ts @@ -0,0 +1,153 @@ +/** + * A 20-byte account address. Declared locally rather than imported from viem so + * this package stays dependency-free and can be consumed by the keeper (Node) + * and the UI (bundler) without pinning either to a viem version. Structurally + * identical to viem's `Address`, so values pass between them freely. + */ +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`. + * + * 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)` + * per side — piecewise-linear in P with one kink each, at the aggregate breakeven + * `value / delta × 10^tokenDecimals`. + * + * `delta` uses the `ILinearMarket` convention (scaled by `10^tokenDecimals`), so a + * side's mark value at price P is `P × delta / 10^tokenDecimals` regardless of the + * venue's own quantity decimals. That is why the snapshot stores delta rather than + * raw quantity: it makes perps and futures the same arithmetic. + */ +export interface RestingOrders { + /** Σ|q| over resting bids, scaled by 10^tokenDecimals. Unsigned. */ + buyDelta: bigint; + /** Σ|q| over resting asks, same scale. Unsigned. */ + sellDelta: bigint; + /** Σ q × limitPrice over resting bids (token decimals). */ + buyValue: bigint; + /** Σ q × limitPrice over resting asks (token decimals). */ + sellValue: bigint; +} + +/** + * Per-account inputs needed to evaluate `mmRequired(P)` and `imRequired(P)` + * off-chain at an arbitrary spot price `P`. Captured as a snapshot so the + * 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` + * + * Bigints throughout because PME math is performed in token-decimal units + * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers + * lose precision at the dollar level for typical position sizes. + */ +export interface AccountSnapshot { + user: Address; + /** Vault balance (token decimals). */ + balance: bigint; + + /** Perps single netted position (zero-qty if user has no perp exposure). */ + perp: { + /** Signed; +long, −short. Scaled by 10^perpQuantityDecimals. */ + netQty: bigint; + /** Token decimals (matches `getMarketPrice`). */ + entryPrice: bigint; + /** Resting perps book. */ + orders: RestingOrders; + /** `max(0, getRiskView(user).pendingFunding)` snapshot (token decimals). */ + fundingOwed: bigint; + }; + + /** + * One entry per active futures expiry. Unilateral aggregate per + * `(user, expirationAt)`: signed `netQuantity` (whole contracts) + + * `netEntryValue` (token decimals) so unrealized PnL is + * `mark * netQuantity - netEntryValue`, where `mark` is the expiry's pinned + * settlement price once it has one and the live index until then. + */ + futures: { + positions: Array<{ + expirationAt: bigint; + /** Signed whole contracts (+long / −short). */ + netQuantity: bigint; + /** Token decimals; `sum(fillPrice * signedFillQty)`. */ + netEntryValue: bigint; + /** + * `Futures.settlementPrice(expirationAt)`; `0` until the expiry settles. + * + * Non-zero means the leg is settled but not yet swept out of + * `participantActiveExpirationAts`, which `getRiskView` treats specially: + * the delta leaves `netPositionDelta` (the price is pinned, so there is no + * directional risk left) while the PnL stays marked at this frozen price + * rather than the live one. Both effects are constant in P, so a settled + * leg drops out of the stress term and contributes only an offset to the + * unrealized-PnL term. + */ + settlementPrice: bigint; + }>; + /** Resting futures book, collapsed across expiries as the venue reports it. */ + orders: RestingOrders; + }; +} + +/** + * Which of the two requirements is being evaluated. + * + * Not merely a shock selector. The engine's unrealized-PnL term is clamped once + * per market for IM and once over the portfolio-wide signed sum for MM, so the + * two requirements are different piecewise-linear functions of price with + * different kink sets — see `mm.ts` and `solve.ts`. + */ +export type MarginRequirement = "im" | "mm"; + +/** + * Engine-wide constants needed by the off-chain MM math. Read once during + * snapshot setup and cached — they only change on PME admin transactions. + */ +export interface MMParams { + /** WAD-scaled (e.g. 0.05e18 = 5%). */ + imSpotShock: bigint; + /** WAD-scaled (e.g. 0.10e18 = 10%). */ + mmSpotShock: bigint; + /** Decimals of the venues' answer (USDC = 6). */ + tokenDecimals: number; + /** Perps quantity decimals (typically 6). */ + perpQuantityDecimals: number; +} + +/** + * Per-account price thresholds derived from the snapshot. `undefined` means + * the user is structurally not liquidatable on that side (e.g. flat or + * already deeply healthy at any plausible price). + */ +export interface PriceThresholds { + user: Address; + /** Liquidatable when spot drops to or below this. */ + liqDown: bigint | undefined; + /** Liquidatable when spot rises to or above this. */ + liqUp: bigint | undefined; +} + +/** + * Per-account IM-utilization alert thresholds. Same {down, up} pattern as + * `PriceThresholds`, just one set per severity. `undefined` on a level + * means the user is already over (or structurally cannot reach) that + * level — the sweep alert path covers the "already over" case. + */ +export interface AlertThresholds { + user: Address; + warnDown: bigint | undefined; + warnUp: bigint | undefined; + critDown: bigint | undefined; + critUp: bigint | undefined; +} + +/** One expiry leg of a futures close-to-IM batch. */ +export interface FuturesCloseLeg { + expirationAt: bigint; + /** Absolute contracts to close toward zero (≤ |netQuantity|). */ + closeQty: bigint; +} diff --git a/portfolio-margin/tests/mm.test.ts b/portfolio-margin/tests/mm.test.ts index c082f01..cbfeda2 100644 --- a/portfolio-margin/tests/mm.test.ts +++ b/portfolio-margin/tests/mm.test.ts @@ -1,17 +1,25 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import type { Address } from "viem"; import { + fillLoss, + futuresUnrealizedPnl, imRequired, imSurplus, mmRequired, mmSurplus, netDeltaWad, - perpUnrealizedLoss, - futuresUnrealizedLoss, + perpUnrealizedPnl, stressLoss, -} from "../../src/predict/mm.ts"; -import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; + unrealizedLoss, + venueFillLoss, + worstLegStressLoss, +} from "../src/mm.ts"; +import type { + AccountSnapshot, + Address, + MMParams, + RestingOrders, +} from "../src/types.ts"; const USER = "0x1111111111111111111111111111111111111111" as Address; @@ -24,16 +32,29 @@ const PARAMS: MMParams = { const QTY_SCALE = 10n ** 6n; +const EXPIRY_A = 1_756_416_000n; +const EXPIRY_B = 1_759_008_000n; + +/** An empty book on one venue. */ +const NO_ORDERS: RestingOrders = { + buyDelta: 0n, + sellDelta: 0n, + buyValue: 0n, + sellValue: 0n, +}; + /** * Skeleton with everything zeroed — tests override the bits they care about * so each case stays focused on the math under test. */ -function emptySnapshot(overrides: Partial = {}): AccountSnapshot { +function emptySnapshot( + overrides: Partial = {}, +): AccountSnapshot { return { user: USER, balance: 0n, - perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n }, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, ...overrides, }; } @@ -46,14 +67,24 @@ describe("predict/mm: netDeltaWad", () => { it("converts a long perp position to WAD using qty decimals", () => { // 1.5 contracts long → 1.5 * 1e18 = 1.5e18 WAD delta. const snap = emptySnapshot({ - perp: { netQty: 1_500_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1_500_000n, + entryPrice: 100n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); assert.equal(netDeltaWad(snap, PARAMS), 1_500_000_000_000_000_000n); }); it("subtracts a short perp position", () => { const snap = emptySnapshot({ - perp: { netQty: -2_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: -2_000_000n, + entryPrice: 100n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); assert.equal(netDeltaWad(snap, PARAMS), -2_000_000_000_000_000_000n); }); @@ -62,8 +93,15 @@ describe("predict/mm: netDeltaWad", () => { // Buyer of 1 contract → +1 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { - positions: [{ expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], - orderMargin: 0n, + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, }, }); assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); @@ -72,8 +110,15 @@ describe("predict/mm: netDeltaWad", () => { it("subtracts futures seller delta", () => { const snap = emptySnapshot({ futures: { - positions: [{ expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], - orderMargin: 0n, + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: -1n, + netEntryValue: -50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, }, }); assert.equal(netDeltaWad(snap, PARAMS), -1n * 10n ** 18n); @@ -81,13 +126,28 @@ describe("predict/mm: netDeltaWad", () => { it("sums perps + futures legs into one signed delta", () => { const snap = emptySnapshot({ - perp: { netQty: 1_000_000n, entryPrice: 100n, orderMargin: 0n, fundingOwed: 0n }, // +1e18 + perp: { + netQty: 1_000_000n, + entryPrice: 100n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, // +1e18 futures: { positions: [ - { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, - { expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -60n }, + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + { + expirationAt: 1_756_416_000n, + netQuantity: -1n, + netEntryValue: -60n, + settlementPrice: 0n, + }, ], - orderMargin: 0n, + orders: NO_ORDERS, }, }); // Perp +1e18; futures +1e18 - 1e18 = 0 → net = +1e18. @@ -113,8 +173,18 @@ describe("predict/mm: stressLoss", () => { }); it("uses |delta| (sign is irrelevant — worst-case scenario)", () => { - const long = stressLoss(1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); - const short = stressLoss(-1n * 10n ** 18n, PARAMS.mmSpotShock, 100_000_000n, 6); + const long = stressLoss( + 1n * 10n ** 18n, + PARAMS.mmSpotShock, + 100_000_000n, + 6, + ); + const short = stressLoss( + -1n * 10n ** 18n, + PARAMS.mmSpotShock, + 100_000_000n, + 6, + ); assert.equal(short, long); }); @@ -131,111 +201,496 @@ describe("predict/mm: stressLoss", () => { }); }); -describe("predict/mm: perpUnrealizedLoss", () => { +describe("predict/mm: perpUnrealizedPnl", () => { it("returns 0 for a flat user", () => { - assert.equal(perpUnrealizedLoss(emptySnapshot(), PARAMS, 100_000_000n), 0n); + assert.equal(perpUnrealizedPnl(emptySnapshot(), PARAMS, 100_000_000n), 0n); }); - it("returns 0 for a profitable long (P > entry)", () => { + it("is positive for a profitable long (P > entry)", () => { const snap = emptySnapshot({ - perp: { netQty: 1_000_000n, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); - assert.equal(perpUnrealizedLoss(snap, PARAMS, 110_000_000n), 0n); + assert.equal(perpUnrealizedPnl(snap, PARAMS, 110_000_000n), 10_000_000n); }); - it("returns the underwater amount for a long below entry (linear in price)", () => { - // 1 contract long at $100, P = $90 → loss = ($100 - $90) * 1 = $10. + it("is negative for a long below entry (linear in price)", () => { + // 1 contract long at $100, P = $90 → pnl = ($90 - $100) * 1 = -$10. const snap = emptySnapshot({ - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); - assert.equal(perpUnrealizedLoss(snap, PARAMS, 90_000_000n), 10_000_000n); + assert.equal(perpUnrealizedPnl(snap, PARAMS, 90_000_000n), -10_000_000n); + }); + + it("is negative for a short above entry", () => { + // 1 contract short at $100, P = $110 → pnl = -($110 - $100) * 1 = -$10. + const snap = emptySnapshot({ + perp: { + netQty: -1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + assert.equal(perpUnrealizedPnl(snap, PARAMS, 110_000_000n), -10_000_000n); + }); +}); + +/** + * An expiry that has settled but has not yet been swept out of the participant's + * active set. `Futures.getRiskView` drops its delta (the price is pinned, so it + * cannot move again) while still marking its PnL at that frozen price. Both halves + * have to hold off-chain or the keeper prices a leg that no longer carries risk. + */ +describe("predict/mm: settled-but-unswept expiries", () => { + /** One live contract alongside five settled at $60 against a $50 entry. */ + function withSettledLeg(): AccountSnapshot { + return emptySnapshot({ + futures: { + positions: [ + { expirationAt: EXPIRY_A, netQuantity: 1n, netEntryValue: 50n, settlementPrice: 0n }, + { expirationAt: EXPIRY_B, netQuantity: 5n, netEntryValue: 250n, settlementPrice: 60n }, + ], + orders: NO_ORDERS, + }, + }); + } + + it("leaves a settled leg out of net delta", () => { + assert.equal( + netDeltaWad(withSettledLeg(), PARAMS), + 1n * 10n ** 18n, + "only the live contract is stressed — the settled five cannot move with spot", + ); }); - it("returns the underwater amount for a short above entry", () => { - // 1 contract short at $100, P = $110 → loss = ($110 - $100) * 1 = $10. + it("marks a settled leg at its pinned price rather than the hypothetical spot", () => { + const snap = withSettledLeg(); + // Settled leg: 5 × ($60 − $50) = +$50, fixed. Live leg: P × 1 − 50. + assert.equal(futuresUnrealizedPnl(snap, 40n), 50n + (40n - 50n)); + assert.equal(futuresUnrealizedPnl(snap, 90n), 50n + (90n - 50n)); + }); + + it("carries a fully settled book as PnL alone, with no stress term left", () => { const snap = emptySnapshot({ - perp: { netQty: -1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + futures: { + positions: [ + { expirationAt: EXPIRY_A, netQuantity: 2n, netEntryValue: 200n, settlementPrice: 60n }, + ], + orders: NO_ORDERS, + }, }); - assert.equal(perpUnrealizedLoss(snap, PARAMS, 110_000_000n), 10_000_000n); + + assert.equal(netDeltaWad(snap, PARAMS), 0n, "nothing left to stress"); + // 2 × ($60 − $100) = −$80, and it stays that whatever spot does next. + for (const P of [1n, 60n, 1_000_000n]) { + assert.equal(futuresUnrealizedPnl(snap, P), -80n); + assert.equal( + unrealizedLoss(snap, PARAMS, P, "mm"), + 80n, + "a settled loss is a debt awaiting sweep, not an exposure that reprices", + ); + } }); }); -describe("predict/mm: futuresUnrealizedLoss", () => { +describe("predict/mm: futuresUnrealizedPnl", () => { it("returns 0 with no positions", () => { - assert.equal(futuresUnrealizedLoss(emptySnapshot(), 100_000_000n), 0n); + assert.equal(futuresUnrealizedPnl(emptySnapshot(), 100_000_000n), 0n); }); it("buyer loses when P drops below entry (no duration factor)", () => { const snap = emptySnapshot({ futures: { - positions: [{ expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }], - orderMargin: 0n, + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, }, }); - // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10. loss = 10. - assert.equal(futuresUnrealizedLoss(snap, 40n), 10n); + // pnl = P * qty - entryValue = 40 - 50 = -10. + assert.equal(futuresUnrealizedPnl(snap, 40n), -10n); }); it("seller loses when P rises above entry", () => { const snap = emptySnapshot({ futures: { - positions: [{ expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -50n }], - orderMargin: 0n, + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: -1n, + netEntryValue: -50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, }, }); - assert.equal(futuresUnrealizedLoss(snap, 60n), 10n); + assert.equal(futuresUnrealizedPnl(snap, 60n), -10n); }); - it("sums losses across multiple positions; profitable legs do not net out", () => { + it("nets signed PnL across expiries into one number, as the venue does", () => { + // Calendar spread: long the near expiry at $50, short the far one at $30. + // `Futures.getRiskView` accumulates one signed `totalPnl` over both, so this + // is the only futures number the engine ever sees. const snap = emptySnapshot({ futures: { positions: [ - { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50n }, // P=40 → loses 10 - { expirationAt: 1_756_416_000n, netQuantity: -1n, netEntryValue: -30n }, // P=40 → loses 10 + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, // P=60 → +10 + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30n, + settlementPrice: 0n, + }, // P=60 → -30 ], - orderMargin: 0n, + orders: NO_ORDERS, }, }); - // Loss is sum of *losing* legs only (consistent with `max(0, -pnl)` per leg - // mirroring the on-chain `getUnrealizedPnl` aggregation, which - // would be 0 net but PME treats them piecewise via stress + per-leg PnL). - // Here both happen to be losing — buyer down, seller up. - assert.equal(futuresUnrealizedLoss(snap, 40n), 20n); + assert.equal(futuresUnrealizedPnl(snap, 60n), -20n); + }); +}); + +describe("predict/mm: unrealizedLoss", () => { + it("charges both losing futures legs under either clamp", () => { + const snap = emptySnapshot({ + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, // P=40 → -10 + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30n, + settlementPrice: 0n, + }, // P=40 → -10 + ], + orders: NO_ORDERS, + }, + }); + // Both legs lose, so netting has nothing to cancel and the netted sum (-20) + // charges exactly what the two legs charge separately. IM and MM agree here; + // they only diverge once a gain is present. + assert.equal(unrealizedLoss(snap, PARAMS, 40n, "im"), 20n); + assert.equal(unrealizedLoss(snap, PARAMS, 40n, "mm"), 20n); + }); + + it("nets a futures calendar spread across expiries even on the IM path", () => { + // Long 1 @ $50 and short 1 @ $30, marked at $60: +$10 against -$30. + // + // The venue nets first and reports -$20, and the engine clamps that single + // number — so IM charges $20 even though it clamps per market. Clamping per + // *expiry* (which this module used to do) would charge the $30 leg in full and + // over-margin every calendar spread by the offsetting leg's gain. + const snap = emptySnapshot({ + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50_000_000n, + settlementPrice: 0n, + }, + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + assert.equal(futuresUnrealizedPnl(snap, 60_000_000n), -20_000_000n); + assert.equal(unrealizedLoss(snap, PARAMS, 60_000_000n, "im"), 20_000_000n); + assert.equal(unrealizedLoss(snap, PARAMS, 60_000_000n, "mm"), 20_000_000n); + }); + + it("IM charges a cross-venue loss in full; MM nets it against the other venue's gain", () => { + // Perp long 1 @ $100 (-$10 at the $90 mark) hedged by a futures long 1 @ $80 + // (+$10 at the same mark). One vault, one currency, net zero. + const snap = emptySnapshot({ + balance: 100_000_000n, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 80_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + const P = 90_000_000n; + assert.equal(perpUnrealizedPnl(snap, PARAMS, P), -10_000_000n); + assert.equal(futuresUnrealizedPnl(snap, P), 10_000_000n); + + // IM clamps per market: the losing venue is charged, the winning one is invisible. + assert.equal(unrealizedLoss(snap, PARAMS, P, "im"), 10_000_000n); + // MM clamps the sum once: the gain offsets the loss exactly. + assert.equal(unrealizedLoss(snap, PARAMS, P, "mm"), 0n); + + // And it shows up in the requirements. Net delta is 2 contracts long, so stress + // is $9 at the 5% MM shock and $18 at the 10% IM shock. + assert.equal(mmRequired(snap, PARAMS, P), 9_000_000n); + assert.equal(imRequired(snap, PARAMS, P), 18_000_000n + 10_000_000n); + }); + + it("MM never goes below zero — a net gain funds no reduction", () => { + // Perp +$10, futures +$10: the netted sum is a gain, and the clamp floors it. + const snap = emptySnapshot({ + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 100_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + const P = 110_000_000n; + assert.equal(unrealizedLoss(snap, PARAMS, P, "mm"), 0n); + assert.equal(unrealizedLoss(snap, PARAMS, P, "im"), 0n); }); }); describe("predict/mm: mmRequired / mmSurplus / imRequired / imSurplus", () => { - it("for an idle account, all four return only the constant add-ons", () => { + it("for an idle account with no orders, all four return only owed funding", () => { const snap = emptySnapshot({ balance: 1_000n, - perp: { netQty: 0n, entryPrice: 0n, orderMargin: 100n, fundingOwed: 50n }, - futures: { positions: [], orderMargin: 25n }, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 50n }, }); - // No delta → no stress, no PnL. orderMargin + funding = 175. - assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 175n); - assert.equal(imRequired(snap, PARAMS, 100_000_000n), 175n); - assert.equal(mmSurplus(snap, PARAMS, 100_000_000n), 825n); - assert.equal(imSurplus(snap, PARAMS, 100_000_000n), 825n); + // No delta → no stress, no PnL, no fill loss. + assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 50n); + assert.equal(imRequired(snap, PARAMS, 100_000_000n), 50n); + assert.equal(mmSurplus(snap, PARAMS, 100_000_000n), 950n); + assert.equal(imSurplus(snap, PARAMS, 100_000_000n), 950n); + }); + + it("a flat account's resting bid is stressed as post-fill delta, plus its fill loss", () => { + // One contract bid at $101 with the mark at $100. Flat position, so the buy leg + // carries the whole delta and the sell leg is flat. + const snap = emptySnapshot({ + balance: 100_000_000n, + perp: { + netQty: 0n, + entryPrice: 0n, + orders: { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 101_000_000n, + sellValue: 0n, + }, + fundingOwed: 50n, + }, + }); + // Stress on 1 delta: $5 at the 5% MM shock, $10 at the 10% IM shock. + // Fill loss: the bid pays $101 for something marked at $100 → $1. + assert.equal( + mmRequired(snap, PARAMS, 100_000_000n), + 5_000_000n + 1_000_000n + 50n, + ); + assert.equal( + imRequired(snap, PARAMS, 100_000_000n), + 10_000_000n + 1_000_000n + 50n, + ); }); it("for a delta-only long, mmRequired equals stress and imRequired is strictly larger", () => { const snap = emptySnapshot({ balance: 0n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); // At entry price: no PnL. Pure stress contribution = $5 (mm) / $10 (im). assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 5_000_000n); assert.equal(imRequired(snap, PARAMS, 100_000_000n), 10_000_000n); }); + it("is not constant in price for an order-only account", () => { + // The regression this whole change exists to fix: the predictor used to treat the + // order reservation as a scalar snapshotted at the current mark, so `mmRequired` + // was flat in P across the order term. It is not. One contract bid at $101: + const snap = emptySnapshot({ + perp: { + netQty: 0n, + entryPrice: 0n, + orders: { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 101_000_000n, + sellValue: 0n, + }, + fundingOwed: 0n, + }, + }); + // Below the bid's own limit the requirement carries both stress and fill loss. + assert.equal( + mmRequired(snap, PARAMS, 100_000_000n), + 5_000_000n + 1_000_000n, + ); + // Above it the fill loss vanishes and only the (larger) stress remains, so the + // requirement *falls* through the breakeven before resuming its climb. + assert.equal(mmRequired(snap, PARAMS, 102_000_000n), 5_100_000n); + assert.equal(mmRequired(snap, PARAMS, 104_000_000n), 5_200_000n); + }); + + it("nets a resting ask against a long instead of charging for it", () => { + const long = emptySnapshot({ + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + // An ask of exactly the position size at the mark: the sell leg lands flat, the + // buy leg is the bare position, and there is no fill loss at the mark. + const hedged = emptySnapshot({ + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: { + buyDelta: 0n, + sellDelta: 1_000_000n, + buyValue: 0n, + sellValue: 100_000_000n, + }, + fundingOwed: 0n, + }, + }); + assert.equal( + mmRequired(hedged, PARAMS, 100_000_000n), + mmRequired(long, PARAMS, 100_000_000n), + ); + }); + + it("charges the worse leg when an oversized ask flips the portfolio short", () => { + // Long 1, asks for 3. Buy leg = |+1| = 1; sell leg = |1 − 3| = 2. The engine must + // take the sell leg, so the requirement is twice the bare position's. + const snap = emptySnapshot({ + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: { + buyDelta: 0n, + sellDelta: 3_000_000n, + buyValue: 0n, + sellValue: 300_000_000n, + }, + fundingOwed: 0n, + }, + }); + assert.equal( + worstLegStressLoss(snap, PARAMS, PARAMS.mmSpotShock, 100_000_000n), + 10_000_000n, + ); + assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 10_000_000n); + }); + + it("clamps fill loss per side rather than letting the sides offset", () => { + // Bid at $101 and ask at $99, both one contract, mark $100. Each side is out of + // the money by $1 and both are charged; a net-across-sides figure would be zero. + const orders = { + buyDelta: 1_000_000n, + sellDelta: 1_000_000n, + buyValue: 101_000_000n, + sellValue: 99_000_000n, + }; + assert.equal(venueFillLoss(orders, 100_000_000n, 6), 2_000_000n); + // Move the mark above both limits: the bid is now a gain (clamped to 0) and the + // ask's loss grows to $2. + assert.equal(venueFillLoss(orders, 101_000_000n, 6), 2_000_000n); + assert.equal(venueFillLoss(orders, 102_000_000n, 6), 3_000_000n); + }); + + it("sums fill loss across both venues", () => { + const snap = emptySnapshot({ + perp: { + netQty: 0n, + entryPrice: 0n, + orders: { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 101_000_000n, + sellValue: 0n, + }, + fundingOwed: 0n, + }, + futures: { + positions: [], + orders: { + buyDelta: 0n, + sellDelta: 1_000_000n, + buyValue: 0n, + sellValue: 97_000_000n, + }, + }, + }); + // Perps bid $1 out of the money, futures ask $3 out of the money. + assert.equal(fillLoss(snap, PARAMS, 100_000_000n), 4_000_000n); + }); + it("mmSurplus drops as price moves below a long's entry (PnL kicks in)", () => { const snap = emptySnapshot({ balance: 50_000_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const atEntry = mmSurplus(snap, PARAMS, 100_000_000n); const below = mmSurplus(snap, PARAMS, 80_000_000n); // Below entry: stress + perp PnL loss compound; surplus shrinks. - assert.ok(below < atEntry, `expected surplus(80) < surplus(100), got ${below} vs ${atEntry}`); + assert.ok( + below < atEntry, + `expected surplus(80) < surplus(100), got ${below} vs ${atEntry}`, + ); }); }); diff --git a/portfolio-margin/tests/solve.test.ts b/portfolio-margin/tests/solve.test.ts index 47e8902..f69b3a6 100644 --- a/portfolio-margin/tests/solve.test.ts +++ b/portfolio-margin/tests/solve.test.ts @@ -1,13 +1,36 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import type { Address } from "viem"; -import { solveAlertThresholds, solveLiquidationThresholds } from "../../src/predict/solve.ts"; -import { imRequired, mmSurplus } from "../../src/predict/mm.ts"; -import type { AccountSnapshot, MMParams } from "../../src/predict/types.ts"; +import { + solveAlertThresholds, + solveLiquidationThresholds, +} from "../src/solve.ts"; +import { + imRequired, + mmRequired, + mmSurplus, + unrealizedLoss, +} from "../src/mm.ts"; +import type { + AccountSnapshot, + Address, + MMParams, + RestingOrders, +} from "../src/types.ts"; const USER = "0x1111111111111111111111111111111111111111" as Address; const QTY_SCALE = 10n ** 6n; +const EXPIRY_A = 1_756_416_000n; +const EXPIRY_B = 1_759_008_000n; + +/** An empty book on one venue. */ +const NO_ORDERS: RestingOrders = { + buyDelta: 0n, + sellDelta: 0n, + buyValue: 0n, + sellValue: 0n, +}; + const PARAMS: MMParams = { imSpotShock: 10n ** 17n, // 10% mmSpotShock: 5n * 10n ** 16n, // 5% @@ -15,12 +38,14 @@ const PARAMS: MMParams = { perpQuantityDecimals: 6, }; -function emptySnapshot(overrides: Partial = {}): AccountSnapshot { +function emptySnapshot( + overrides: Partial = {}, +): AccountSnapshot { return { user: USER, balance: 0n, - perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n }, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, ...overrides, }; } @@ -41,7 +66,10 @@ function assertCrossing( side: "down" | "up", ): void { const sAt = mmSurplus(snap, params, threshold); - assert.ok(sAt >= 0n, `expected surplus(${threshold}) ≥ 0 (safe side), got ${sAt}`); + assert.ok( + sAt >= 0n, + `expected surplus(${threshold}) ≥ 0 (safe side), got ${sAt}`, + ); if (side === "down") { // Going further down should not increase surplus. const sFurther = mmSurplus(snap, params, threshold - 1n); @@ -58,18 +86,87 @@ function assertCrossing( } } +/** + * Walk a grid from `threshold` to `currentPrice` and require the account to be + * healthy the whole way. This is the property a missing kink actually violates: the + * bisector does not usually return a slightly wrong threshold, it skips a crossing + * and reports a further one, leaving liquidatable prices between here and there. + */ +function assertNoCrossingBetween( + snap: AccountSnapshot, + params: MMParams, + threshold: bigint, + currentPrice: bigint, +): void { + const lo = threshold < currentPrice ? threshold : currentPrice; + const hi = threshold < currentPrice ? currentPrice : threshold; + const step = (hi - lo) / 64n; + if (step <= 0n) return; + for (let p = lo; p <= hi; p += step) { + const s = mmSurplus(snap, params, p); + assert.ok( + s >= 0n, + `expected surplus(${p}) >= 0 between ${threshold} and ${currentPrice}, got ${s}`, + ); + } +} + describe("predict/solve: solveLiquidationThresholds", () => { it("returns no thresholds when the user is currently underwater", () => { // Long with no balance → already underwater at any reasonable price. const snap = emptySnapshot({ balance: 0n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); assert.equal(out.liqDown, undefined); assert.equal(out.liqUp, undefined); }); + it("finds a threshold hidden behind a fill-loss breakeven kink", () => { + // A flat account whose only exposure is a resting bid *is* liquidatable now, and + // its requirement is non-monotone in price: it drops through the bid's breakeven + // (the fill loss vanishes) before resuming its climb with stress. The bisector only + // finds the down-side crossing if the breakeven is in its kink set — without it, + // the interval containing the crossing is not monotone and bisection walks past it. + const snap = emptySnapshot({ + balance: 8_000_000n, + perp: { + netQty: 0n, + entryPrice: 0n, + // 1 contract bid at $101; at $100 the requirement is $5 stress + $1 fill loss. + orders: { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 101_000_000n, + sellValue: 0n, + }, + fundingOwed: 0n, + }, + }); + assert.ok(mmSurplus(snap, PARAMS, 100_000_000n) > 0n, "starts healthy"); + const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); + // Falling price grows the bid's fill loss dollar-for-dollar, so there is a + // down-side crossing even though the position is flat. + assert.notEqual( + out.liqDown, + undefined, + "a resting bid alone can be liquidated on a drop", + ); + if (out.liqDown !== undefined) { + assert.ok(mmSurplus(snap, PARAMS, out.liqDown) >= 0n); + assert.ok( + mmSurplus(snap, PARAMS, out.liqDown - 1n) < 0n, + "one tick lower is unsafe", + ); + } + }); + it("returns no thresholds for a flat user — they're never liquidatable", () => { const snap = emptySnapshot({ balance: 1_000_000_000n }); const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); @@ -85,7 +182,12 @@ describe("predict/solve: solveLiquidationThresholds", () => { // crosses 0 at P = 80 / 0.95 ≈ 84.21 const snap = emptySnapshot({ balance: 20_000_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); assert.notEqual(out.liqDown, undefined); @@ -102,7 +204,12 @@ describe("predict/solve: solveLiquidationThresholds", () => { it("finds an upside threshold for a leveraged net-short perp position", () => { const snap = emptySnapshot({ balance: 20_000_000n, - perp: { netQty: -1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: -1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); assert.notEqual(out.liqUp, undefined); @@ -124,7 +231,12 @@ describe("predict/solve: solveLiquidationThresholds", () => { // = balance - 0.05P. Crosses zero at P = balance / 0.05 = 20*$1M / 0.05 = $400M. const snap = emptySnapshot({ balance: 20_000_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const out = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); assert.notEqual(out.liqUp, undefined); @@ -146,9 +258,14 @@ describe("predict/solve: solveLiquidationThresholds", () => { balance: 30_000_000n, futures: { positions: [ - { expirationAt: 1_756_416_000n, netQuantity: 1n, netEntryValue: 50_000_000n }, + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50_000_000n, + settlementPrice: 0n, + }, ], - orderMargin: 0n, + orders: NO_ORDERS, }, }); const out = solveLiquidationThresholds(snap, PARAMS, 50_000_000n); @@ -158,22 +275,196 @@ describe("predict/solve: solveLiquidationThresholds", () => { } }); - it("threshold tightens when orderMargin and fundingOwed eat balance headroom", () => { + it("finds the threshold when the aggregate breakeven is at neither leg's entry", () => { + // Perp long 2 @ $100 against a futures short 1 @ $50. MM clamps the two venues' + // signed sum once, and that sum — 2(P − 100) − (P − 50) = P − 150 — breaks even + // at $150. Not $100, not $50: the only price where the MM PnL term turns is one + // no individual leg knows about, and it is the apex of the surplus tent. + const snap = emptySnapshot({ + balance: 30_000_000n, + perp: { + netQty: 2n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: -1n, + netEntryValue: -50_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + // At $150 the perp's +$100 and the futures' −$100 cancel: MM charges nothing, + // while IM (clamping per market) still charges the futures leg's loss in full. + assert.equal(unrealizedLoss(snap, PARAMS, 150_000_000n, "mm"), 0n); + assert.equal( + unrealizedLoss(snap, PARAMS, 150_000_000n, "im"), + 100_000_000n, + ); + + const currentPrice = 200_000_000n; + assert.ok(mmSurplus(snap, PARAMS, currentPrice) > 0n, "starts healthy"); + const out = solveLiquidationThresholds(snap, PARAMS, currentPrice); + + // Below the apex the netted loss grows $1 per $1 of price while stress shrinks + // by 5c: surplus = 30 − (150 − 0.95P), zero at 120 / 0.95 ≈ $126.32. + assert.notEqual(out.liqDown, undefined); + if (out.liqDown !== undefined) { + assert.ok( + out.liqDown > 126_000_000n && out.liqDown < 127_000_000n, + `expected liqDown ≈ $126.32, got ${out.liqDown}`, + ); + assertCrossing(snap, PARAMS, out.liqDown, "down"); + assertNoCrossingBetween(snap, PARAMS, out.liqDown, currentPrice); + } + // Above the apex only stress remains: 30 / 0.05 = $600. + assert.notEqual(out.liqUp, undefined); + if (out.liqUp !== undefined) { + assert.ok( + out.liqUp > 599_000_000n && out.liqUp < 601_000_000n, + `expected liqUp ≈ $600, got ${out.liqUp}`, + ); + assertNoCrossingBetween(snap, PARAMS, out.liqUp, currentPrice); + } + }); + + it("turns MM once at the aggregate breakeven and IM once per venue", () => { + // The kink sets the two solvers must enumerate, read straight off the + // requirements. Same portfolio as above: perp long 2 @ $100, futures short 1 @ $50. + const snap = emptySnapshot({ + balance: 30_000_000n, + perp: { + netQty: 2n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: -1n, + netEntryValue: -50_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + const turningPoints = (f: (P: bigint) => bigint): bigint[] => { + const step = 1_000_000n; + const turns: bigint[] = []; + let prevSlope: bigint | undefined; + for (let P = 20_000_000n; P <= 260_000_000n; P += step) { + const slope = f(P + step) - f(P); + if (prevSlope !== undefined && slope !== prevSlope) turns.push(P); + prevSlope = slope; + } + return turns; + }; + // MM clamps the venues' sum once, so it turns once — and at a price that is + // neither leg's entry. + assert.deepEqual( + turningPoints((P) => mmRequired(snap, PARAMS, P)), + [150_000_000n], + ); + // IM clamps each venue separately, so it turns at each venue's own breakeven and + // not at the aggregate one. + assert.deepEqual( + turningPoints((P) => imRequired(snap, PARAMS, P)), + [50_000_000n, 100_000_000n], + ); + }); + + it("nets a futures calendar spread across expiries when placing the threshold", () => { + // Long 2 @ $60 and short 1 @ $30 in the same venue. The venue reports one signed + // number, P·1 − 90, so the requirement turns at $90 and the spread's own entries + // ($60, $30) are not kinks at all. Clamping per expiry would charge the losing + // expiry in full and put the threshold too high — a false liquidation call. + const snap = emptySnapshot({ + balance: 20_000_000n, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 2n, + netEntryValue: 120_000_000n, + settlementPrice: 0n, + }, + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + // At a $75 mark the long leg is +$30 and the short leg is −$45; netted, the venue + // reports −$15 and that is what both requirements charge. Per-expiry clamping + // would charge the short leg's $45 in full and ignore the long leg entirely. + assert.equal(unrealizedLoss(snap, PARAMS, 75_000_000n, "mm"), 15_000_000n); + assert.equal(unrealizedLoss(snap, PARAMS, 75_000_000n, "im"), 15_000_000n); + + const currentPrice = 100_000_000n; + assert.ok(mmSurplus(snap, PARAMS, currentPrice) > 0n, "starts healthy"); + const out = solveLiquidationThresholds(snap, PARAMS, currentPrice); + // surplus(P) = 20 − (0.05P + max(0, 90 − P)); below the apex that is 0.95P − 70, + // zero at ≈ $73.68. + assert.notEqual(out.liqDown, undefined); + if (out.liqDown !== undefined) { + assert.ok( + out.liqDown > 73_000_000n && out.liqDown < 74_000_000n, + `expected liqDown ≈ $73.68, got ${out.liqDown}`, + ); + assertCrossing(snap, PARAMS, out.liqDown, "down"); + assertNoCrossingBetween(snap, PARAMS, out.liqDown, currentPrice); + } + }); + + it("threshold tightens when resting orders and fundingOwed eat balance headroom", () => { const base = emptySnapshot({ balance: 20_000_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const withDrag = emptySnapshot({ balance: 20_000_000n, perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, - orderMargin: 5_000_000n, + // A second contract bid at the mark: doubles the buy-leg delta and so the + // stress term, with no fill loss of its own at $100. + orders: { + buyDelta: 1_000_000n, + sellDelta: 0n, + buyValue: 100_000_000n, + sellValue: 0n, + }, fundingOwed: 1_000_000n, }, }); - const baseLiq = solveLiquidationThresholds(base, PARAMS, 100_000_000n).liqDown; - const dragLiq = solveLiquidationThresholds(withDrag, PARAMS, 100_000_000n).liqDown; + const baseLiq = solveLiquidationThresholds( + base, + PARAMS, + 100_000_000n, + ).liqDown; + const dragLiq = solveLiquidationThresholds( + withDrag, + PARAMS, + 100_000_000n, + ).liqDown; assert.notEqual(baseLiq, undefined); assert.notEqual(dragLiq, undefined); if (baseLiq !== undefined && dragLiq !== undefined) { @@ -193,7 +484,13 @@ describe("predict/solve: solveAlertThresholds", () => { it("returns all-undefined for a flat user (no IM utilization possible)", () => { const snap = emptySnapshot({ balance: 1_000_000_000n }); - const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + const out = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); assert.equal(out.warnDown, undefined); assert.equal(out.warnUp, undefined); assert.equal(out.critDown, undefined); @@ -203,9 +500,20 @@ describe("predict/solve: solveAlertThresholds", () => { it("returns all-undefined when balance is zero (utilization undefined)", () => { const snap = emptySnapshot({ balance: 0n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); - const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + const out = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); assert.equal(out.warnDown, undefined); assert.equal(out.critDown, undefined); }); @@ -215,14 +523,29 @@ describe("predict/solve: solveAlertThresholds", () => { // crossings exist on the downside (user is liquidatable around $52.6). const snap = emptySnapshot({ balance: 50_000_000n, // $50 collateral - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const liq = solveLiquidationThresholds(snap, PARAMS, 100_000_000n); - const alerts = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + const alerts = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); assert.notEqual(liq.liqDown, undefined); assert.notEqual(alerts.warnDown, undefined); assert.notEqual(alerts.critDown, undefined); - if (liq.liqDown !== undefined && alerts.warnDown !== undefined && alerts.critDown !== undefined) { + if ( + liq.liqDown !== undefined && + alerts.warnDown !== undefined && + alerts.critDown !== undefined + ) { // warn should fire first (higher price), then crit, then liquidation. assert.ok( alerts.warnDown > alerts.critDown, @@ -238,9 +561,20 @@ describe("predict/solve: solveAlertThresholds", () => { it("at the warn threshold, imRequired ≈ warnUtil * balance", () => { const snap = emptySnapshot({ balance: 50_000_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); - const alerts = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + const alerts = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); if (alerts.warnDown !== undefined) { const target = (WARN_PPM * snap.balance) / 1_000_000n; const im = imRequired(snap, PARAMS, alerts.warnDown); @@ -254,13 +588,95 @@ describe("predict/solve: solveAlertThresholds", () => { } }); + it("kinks the IM path at the futures venue's netted breakeven, not at each entry", () => { + // Same calendar spread as above: long 2 @ $60, short 1 @ $30, netting to P − 90. + // IM clamps per *market*, and the futures market is already netted across its + // expiries by `getRiskView`, so the IM requirement turns at $90 and nowhere else. + const snap = emptySnapshot({ + balance: 20_000_000n, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 2n, + netEntryValue: 120_000_000n, + settlementPrice: 0n, + }, + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + // V-shaped around $90 — the clamp turning over is the only kink in the term. + assert.ok( + imRequired(snap, PARAMS, 89_000_000n) > + imRequired(snap, PARAMS, 90_000_000n), + ); + assert.ok( + imRequired(snap, PARAMS, 91_000_000n) > + imRequired(snap, PARAMS, 90_000_000n), + ); + // Neither leg's own entry turns it: the requirement falls straight through both. + assert.ok( + imRequired(snap, PARAMS, 29_000_000n) > + imRequired(snap, PARAMS, 31_000_000n), + ); + assert.ok( + imRequired(snap, PARAMS, 59_000_000n) > + imRequired(snap, PARAMS, 61_000_000n), + ); + + const alerts = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); + // Both sides are reachable: down through the netted loss, up through stress. + for (const threshold of [alerts.warnDown, alerts.warnUp]) { + assert.notEqual(threshold, undefined); + if (threshold === undefined) continue; + const target = (WARN_PPM * snap.balance) / 1_000_000n; + const im = imRequired(snap, PARAMS, threshold); + const slop = im > target ? im - target : target - im; + assert.ok( + slop < target / 1_000n, + `imRequired(${threshold}) = ${im}, target = ${target}`, + ); + } + // The down-side warn sits where 90 − 0.9P = 17, i.e. ≈ $81.11. + if (alerts.warnDown !== undefined) { + assert.ok( + alerts.warnDown > 81_000_000n && alerts.warnDown < 81_200_000n, + `expected warnDown ≈ $81.11, got ${alerts.warnDown}`, + ); + } + }); + it("returns undefined for a level the user is already past at currentPrice", () => { // Long with tiny balance — already over both warn and crit at current. const snap = emptySnapshot({ balance: 1_000n, - perp: { netQty: 1n * QTY_SCALE, entryPrice: 100_000_000n, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); - const out = solveAlertThresholds(snap, PARAMS, 100_000_000n, WARN_PPM, CRIT_PPM); + const out = solveAlertThresholds( + snap, + PARAMS, + 100_000_000n, + WARN_PPM, + CRIT_PPM, + ); assert.equal(out.warnDown, undefined); assert.equal(out.critDown, undefined); }); diff --git a/portfolio-margin/tests/solveTarget.test.ts b/portfolio-margin/tests/solveTarget.test.ts index c1247a1..8667eb5 100644 --- a/portfolio-margin/tests/solveTarget.test.ts +++ b/portfolio-margin/tests/solveTarget.test.ts @@ -1,17 +1,36 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import type { Address } from "viem"; import { simulateFuturesClose, simulatePerpClose, solveFuturesClosesToTarget, solvePerpCloseToTarget, -} from "../../src/predict/solve.ts"; -import { imSurplus, mmSurplus } from "../../src/predict/mm.ts"; -import type { AccountSnapshot, FuturesCloseLeg, MMParams } from "../../src/predict/types.ts"; +} from "../src/solve.ts"; +import { + futuresUnrealizedPnl, + imSurplus, + mmSurplus, + perpUnrealizedPnl, + unrealizedLoss, +} from "../src/mm.ts"; +import type { + AccountSnapshot, + Address, + FuturesCloseLeg, + MMParams, + RestingOrders, +} from "../src/types.ts"; const USER = "0x1111111111111111111111111111111111111111" as Address; +/** An empty book on one venue. */ +const NO_ORDERS: RestingOrders = { + buyDelta: 0n, + sellDelta: 0n, + buyValue: 0n, + sellValue: 0n, +}; + const PARAMS: MMParams = { imSpotShock: 10n ** 17n, mmSpotShock: 5n * 10n ** 16n, @@ -28,20 +47,28 @@ const ENTRY = 40_000_000n; // $40/contract entry const P_MODERATE = 30_000_000n; // $30: underwater but recoverable const BALANCE = 136_000_000n; -function futuresAgg(netQuantity: bigint, entry: bigint, expirationAt = EXPIRY_A) { +function futuresAgg( + netQuantity: bigint, + entry: bigint, + expirationAt = EXPIRY_A, + settlementPrice = 0n, +) { return { expirationAt, netQuantity, netEntryValue: entry * netQuantity, + settlementPrice, }; } -function futuresSnapshot(overrides: Partial = {}): AccountSnapshot { +function futuresSnapshot( + overrides: Partial = {}, +): AccountSnapshot { return { user: USER, balance: 0n, - perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n }, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, ...overrides, }; } @@ -50,7 +77,7 @@ function futuresSnapshot(overrides: Partial = {}): AccountSnaps function twelveLong(): AccountSnapshot { return futuresSnapshot({ balance: BALANCE, - futures: { positions: [futuresAgg(12n, ENTRY)], orderMargin: 0n }, + futures: { positions: [futuresAgg(12n, ENTRY)], orders: NO_ORDERS }, }); } @@ -62,7 +89,7 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { it("returns an empty set when the account is already healthy", () => { const snap = futuresSnapshot({ balance: 1_000_000_000n, - futures: { positions: [futuresAgg(1n, ENTRY)], orderMargin: 0n }, + futures: { positions: [futuresAgg(1n, ENTRY)], orders: NO_ORDERS }, }); const closes = solveFuturesClosesToTarget(snap, PARAMS, P_MODERATE, FEE); assert.equal(closes.length, 0); @@ -126,7 +153,7 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { futuresAgg(6n, ENTRY, EXPIRY_A), futuresAgg(6n, ENTRY, EXPIRY_B), ], - orderMargin: 0n, + orders: NO_ORDERS, }, }); const P = P_MODERATE; @@ -141,7 +168,10 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { const countB = closes .filter((c) => c.expirationAt === EXPIRY_B) .reduce((s, c) => s + c.closeQty, 0n); - assert.ok(countA >= 1n && countB >= 1n, `both expirations must be reduced (A=${countA}, B=${countB})`); + assert.ok( + countA >= 1n && countB >= 1n, + `both expirations must be reduced (A=${countA}, B=${countB})`, + ); assert.ok( countA - countB <= 1n && countB - countA <= 1n, `closures must be balanced within one contract (A=${countA}, B=${countB})`, @@ -152,6 +182,50 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { assert.ok(imSurplus(after, PARAMS, P) <= 0n); }); + it("closes the expiry whose unit close moves the requirement most, not the biggest book", () => { + // Two long books, equal standalone loss ($20 each) at the $30 mark: 10 lots + // entered at $32, and 2 lots entered at $40. The old ranking scored them by that + // standalone loss, tied, and fell to the notional tiebreak — starting on the + // 10-lot book. But under netting what matters is the requirement's response, and + // one lot of the $40 book carries $10 of the netted loss against the $2 a lot of + // the $32 book carries. The 2-lot book must go first. + const snap = futuresSnapshot({ + balance: 50_000_000n, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 10n, + netEntryValue: 320_000_000n, + settlementPrice: 0n, + }, + { + expirationAt: EXPIRY_B, + netQuantity: 2n, + netEntryValue: 80_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + const P = P_MODERATE; + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); + + // Production passes a zero fee (the on-chain payout is disabled). + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, 0n); + assert.ok(closes.length > 0, "should close something"); + assert.equal( + closes[0]?.expirationAt, + EXPIRY_B, + "highest per-lot requirement drop first", + ); + + const after = simulateFuturesClose(snap, closes, P, 0n); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); + }); + it("balances proportionally when expiries differ in size", () => { const snap = futuresSnapshot({ balance: BALANCE, @@ -160,7 +234,7 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { futuresAgg(8n, ENTRY, EXPIRY_A), futuresAgg(4n, ENTRY, EXPIRY_B), ], - orderMargin: 0n, + orders: NO_ORDERS, }, }); const P = P_MODERATE; @@ -173,7 +247,10 @@ describe("predict/solve: solveFuturesClosesToTarget", () => { .reduce((s, c) => s + c.closeQty, 0n); // A is twice B → roughly 2:1 close ratio when both are touched. if (countA > 0n && countB > 0n) { - assert.ok(countA >= countB, `A=${countA} should close at least as many as B=${countB}`); + assert.ok( + countA >= countB, + `A=${countA} should close at least as many as B=${countB}`, + ); } }); }); @@ -182,7 +259,12 @@ describe("predict/solve: solvePerpCloseToTarget (smoke)", () => { it("returns 0 when healthy", () => { const snap = futuresSnapshot({ balance: 1_000_000_000n, - perp: { netQty: 1_000_000n, entryPrice: ENTRY, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 1_000_000n, + entryPrice: ENTRY, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); assert.equal(solvePerpCloseToTarget(snap, PARAMS, P_MODERATE, FEE), 0n); }); @@ -190,9 +272,148 @@ describe("predict/solve: solvePerpCloseToTarget (smoke)", () => { it("simulatePerpClose reduces qty toward zero", () => { const snap = futuresSnapshot({ balance: BALANCE, - perp: { netQty: 5_000_000n, entryPrice: ENTRY, orderMargin: 0n, fundingOwed: 0n }, + perp: { + netQty: 5_000_000n, + entryPrice: ENTRY, + orders: NO_ORDERS, + fundingOwed: 0n, + }, }); const after = simulatePerpClose(snap, 2_000_000n, P_MODERATE, FEE); assert.equal(after.perp.netQty, 3_000_000n); }); + + it("finds the bounded in-band island when resting asks un-monotone the requirement", () => { + // The precondition the old single bisection rested on, and the reason it had to go. + // Long 10 perp contracts with resting futures asks for 6. Closing the long walks net + // delta from +10 toward 0, so the `netDelta + 0` leg shrinks — but the + // `netDelta − 6` leg turns around at net delta +3 and *grows* from there. The MM + // surplus therefore rises to a peak at 7 contracts closed and falls away again, and + // the healthy set is a bounded island rather than a suffix: + // + // closed: 0 6 7 8 8.5 10 + // mmSurplus −8.0m 0 +1.5m 0 −0.75m −3.0m + // + // A monotone bisection for "first quantity that clears the band" runs off the top of + // that island and lands at ~9_999_999 — a close that leaves the account under MM, + // so the keeper would burn a transaction and the account would stay liquidatable. + const snap: AccountSnapshot = { + user: USER, + balance: 107_000_000n, + perp: { + netQty: 10_000_000n, + entryPrice: ENTRY, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [], + orders: { + buyDelta: 0n, + sellDelta: 6_000_000n, + buyValue: 0n, + sellValue: 180_000_000n, + }, + }, + }; + assert.ok( + mmSurplus(snap, PARAMS, P_MODERATE) < 0n, + "fixture must start underwater", + ); + + const q = solvePerpCloseToTarget(snap, PARAMS, P_MODERATE, FEE); + assert.equal(q, 8_000_000n, "deepest close on the island"); + + const after = simulatePerpClose(snap, q, P_MODERATE, FEE); + assert.ok(mmSurplus(after, PARAMS, P_MODERATE) >= 0n, "reaches MM"); + assert.ok(imSurplus(after, PARAMS, P_MODERATE) <= 0n, "stays under IM"); + + // One unit deeper falls off the island — so this really is the deepest legal close, + // and the on-chain `OverLiquidation` guard has nothing to complain about. + const deeper = simulatePerpClose(snap, q + 1n, P_MODERATE, FEE); + assert.ok( + mmSurplus(deeper, PARAMS, P_MODERATE) < 0n, + "closing more re-breaks MM", + ); + + // And a full close is strictly worse than doing nothing about the asks. + const full = simulatePerpClose(snap, 10_000_000n, P_MODERATE, FEE); + assert.ok(mmSurplus(full, PARAMS, P_MODERATE) < 0n); + }); + + it("finds the in-band close when the netted PnL crosses zero partway through", () => { + // The kink the per-market clamp let us ignore. A futures calendar spread (long 1 + // @ $50 against short 1 @ $150) carries a constant +$100 — zero net quantity, so + // it contributes no delta and no price dependence. The perp is 10 contracts long + // at $45, marked at $30: −$150. + // + // Closing the perp walks its PnL from −$150 to $0, so the portfolio total walks + // from −$50 to +$100 and crosses zero at a third of the way in. MM's clamp turns + // there, and with it the surplus: rising while the netted loss is still being + // erased, falling afterwards once only realized losses and the fee land on the + // balance. The healthy set is a bounded island in the middle. + // + // closed: 0 2.0 10/3 3.48 5.0 10 + // mmSurplus −2.0m 0 +2.0m ~0 −20.5m −88.0m + // + // Without a kink at 10/3 the interval is [0, 10] with both ends negative, + // `nonNegativeRange` reports nothing, and the solver falls back to a full close — + // which leaves the account $88 under MM, so the keeper spends a transaction and + // the account stays liquidatable. + const snap: AccountSnapshot = { + user: USER, + balance: 63_000_000n, + perp: { + netQty: 10_000_000n, + entryPrice: 45_000_000n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + futures: { + positions: [ + { + expirationAt: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50_000_000n, + settlementPrice: 0n, + }, + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -150_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }; + const P = P_MODERATE; + assert.equal(futuresUnrealizedPnl(snap, P), 100_000_000n); + assert.equal(perpUnrealizedPnl(snap, PARAMS, P), -150_000_000n); + // MM nets to a $50 charge; IM charges the perp's $150 and ignores the gain. + assert.equal(unrealizedLoss(snap, PARAMS, P, "mm"), 50_000_000n); + assert.equal(unrealizedLoss(snap, PARAMS, P, "im"), 150_000_000n); + assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); + + const q = solvePerpCloseToTarget(snap, PARAMS, P, FEE); + assert.ok( + q > 0n && q < 10_000_000n, + `expected a strict partial close, got ${q}`, + ); + + const after = simulatePerpClose(snap, q, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n, "post-close: healthy at MM"); + assert.ok(imSurplus(after, PARAMS, P) <= 0n, "post-close: at/under IM"); + + // Deepest on the island: one unit more falls back under MM. + const deeper = simulatePerpClose(snap, q + 1n, P, FEE); + assert.ok(mmSurplus(deeper, PARAMS, P) < 0n, "closing more re-breaks MM"); + + // The fallback the missing kink used to produce. + const full = simulatePerpClose(snap, 10_000_000n, P, FEE); + assert.ok( + mmSurplus(full, PARAMS, P) < 0n, + "a full close does not reach MM", + ); + }); }); diff --git a/portfolio-margin/tsconfig.json b/portfolio-margin/tsconfig.json new file mode 100644 index 0000000..8e75d2b --- /dev/null +++ b/portfolio-margin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "tests"] +} From bb697e8870f422a34a131d5bcdf9dbeede5b541c Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 5 Aug 2026 17:18:05 +0200 Subject: [PATCH 130/180] chore(keeper): lock the portfolio-margin git dependency Co-authored-by: Cursor --- keeper/pnpm-lock.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 6ae0888..2156760 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: '@hashpower/portfolio-margin': - specifier: link:../portfolio-margin - version: link:../portfolio-margin + specifier: github:Lumerin-protocol/collateral-margin#feat/hashprice-usd-1ph-unit&path:/portfolio-margin + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin amaro: specifier: ^1.1.9 version: 1.1.10 @@ -109,6 +109,11 @@ packages: cpu: [x64] os: [win32] + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin': + resolution: {gitHosted: true, integrity: sha512-3P8nQO9gKB7z31n+7lBsJFbhPXxMQQubCILbk+ZU7oPgQWLzvkEUi4BIOazieJ9iL8bCOwAAGB0AqJTTWxcTqg==, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77} + version: 0.1.0 + engines: {node: '>=22'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -379,6 +384,8 @@ snapshots: '@biomejs/cli-win32-x64@2.4.13': optional: true + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin': {} + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': From 4846d1e293561554060d690746e14161750e0cf4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 5 Aug 2026 17:29:55 +0200 Subject: [PATCH 131/180] chore(abi): declare the abi folder as a package Consumers install this folder straight from git, which needs a manifest to resolve. The keeper has been patching one in locally; shipping it here lets the UI depend on the same folder without repeating that patch. Co-authored-by: Cursor --- contracts/abi/package.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 contracts/abi/package.json diff --git a/contracts/abi/package.json b/contracts/abi/package.json new file mode 100644 index 0000000..03e163d --- /dev/null +++ b/contracts/abi/package.json @@ -0,0 +1,7 @@ +{ + "name": "collateral-margin-abi", + "type": "module", + "exports": { + "./*": "./*" + } +} From cded2263d552dee8d4c6904ece31a414591b8263 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 5 Aug 2026 18:36:13 +0200 Subject: [PATCH 132/180] chore(keeper): pin ABIs to the current contracts and drop the manifest patches The pinned ABIs predated the createOrder rename and the getRiskView / getOrderValues additions, so the integration scenarios encoded against a surface the contracts no longer expose. Repinning also picks up the abi manifests now shipped upstream, which is what the two patches existed to synthesize locally. Co-authored-by: Cursor --- keeper/package.json | 8 ++-- keeper/patches/collateral-margin-abi@.patch | 13 ------ .../derivatives-marketplace-abi@.patch | 13 ------ keeper/pnpm-lock.yaml | 44 +++++++++---------- keeper/pnpm-workspace.yaml | 3 -- 5 files changed, 24 insertions(+), 57 deletions(-) delete mode 100644 keeper/patches/collateral-margin-abi@.patch delete mode 100644 keeper/patches/derivatives-marketplace-abi@.patch diff --git a/keeper/package.json b/keeper/package.json index 30c1301..a440c98 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,11 +21,11 @@ "lint:fix": "biome check --write ." }, "dependencies": { - "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#feat/hashprice-usd-1ph-unit&path:/portfolio-margin", + "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/portfolio-margin", "amaro": "^1.1.9", - "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts/abi", + "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#4f068e7bda814a9987fcfd2dd860fdacda7751c0&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/patches/collateral-margin-abi@.patch b/keeper/patches/collateral-margin-abi@.patch deleted file mode 100644 index 9aafff8..0000000 --- a/keeper/patches/collateral-margin-abi@.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/package.json b/package.json -new file mode 100644 -index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 ---- /dev/null -+++ b/package.json -@@ -0,0 +1,7 @@ -+{ -+ "name": "collateral-margin-abi", -+ "type": "module", -+ "exports": { -+ "./*": "./*" -+ } -+} diff --git a/keeper/patches/derivatives-marketplace-abi@.patch b/keeper/patches/derivatives-marketplace-abi@.patch deleted file mode 100644 index 57ad5d6..0000000 --- a/keeper/patches/derivatives-marketplace-abi@.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/package.json b/package.json -new file mode 100644 -index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 ---- /dev/null -+++ b/package.json -@@ -0,0 +1,7 @@ -+{ -+ "name": "derivatives-marketplace-abi", -+ "type": "module", -+ "exports": { -+ "./*": "./*" -+ } -+} diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 2156760..d456fdd 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -4,29 +4,25 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false -patchedDependencies: - collateral-margin-abi: 7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d - derivatives-marketplace-abi: 96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d - importers: .: dependencies: '@hashpower/portfolio-margin': - specifier: github:Lumerin-protocol/collateral-margin#feat/hashprice-usd-1ph-unit&path:/portfolio-margin - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin + specifier: github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/portfolio-margin + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin amaro: specifier: ^1.1.9 version: 1.1.10 collateral-margin-abi: - specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) + specifier: github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/abi futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#4f068e7bda814a9987fcfd2dd860fdacda7751c0&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -109,8 +105,8 @@ packages: cpu: [x64] os: [win32] - '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin': - resolution: {gitHosted: true, integrity: sha512-3P8nQO9gKB7z31n+7lBsJFbhPXxMQQubCILbk+ZU7oPgQWLzvkEUi4BIOazieJ9iL8bCOwAAGB0AqJTTWxcTqg==, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77} + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin': + resolution: {gitHosted: true, path: /portfolio-margin, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4} version: 0.1.0 engines: {node: '>=22'} @@ -207,8 +203,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4} version: 0.0.0 colorette@2.0.20: @@ -217,8 +213,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/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-S71kDrzEuZGW+WlkvdKVp/SHe0f5bpA1HnSJnl2ymC1lcr+T07njTa+ZhG5V49qwyOJlCTADED0zvar8E6o+Ew==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a} version: 0.0.0 end-of-stream@1.4.5: @@ -233,8 +229,8 @@ 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/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-DH1KCNN4FM6f+Et4yGxiHfv+GXKShVNxvBmGPvSWE5y/D046FDtzCdcs9ptaeCTgjWPKiHcB6BmB6wp2NGp+HQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0} version: 0.0.0 help-me@5.0.0: @@ -384,7 +380,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.13': optional: true - '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/cb9b4f6f810da1db58babab5dcadfaf5d8b65f77#path:/portfolio-margin': {} + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin': {} '@noble/ciphers@1.3.0': {} @@ -450,13 +446,13 @@ snapshots: atomic-sleep@1.0.0: {} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d): {} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi: {} colorette@2.0.20: {} dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/abi: {} end-of-stream@1.4.5: dependencies: @@ -468,7 +464,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml index ec4c4f0..18d9b4c 100644 --- a/keeper/pnpm-workspace.yaml +++ b/keeper/pnpm-workspace.yaml @@ -1,5 +1,2 @@ autoInstallPeers: false blockExoticSubdeps: false -patchedDependencies: - collateral-margin-abi: patches/collateral-margin-abi@.patch - derivatives-marketplace-abi: patches/derivatives-marketplace-abi@.patch From 9372f537a57682bb126aede41aac51be004828a6 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 6 Aug 2026 14:20:33 +0200 Subject: [PATCH 133/180] chore(build): upgrade hardhat-viem-abi and name the generated ABI package Every compile was deleting abi/package.json, since the exporter clears the directory and the manifest was committed by hand. The plugin now regenerates it, pinned to collateral-margin-abi so the keeper and UI keep resolving the folder. Co-authored-by: Cursor --- contracts/hardhat.config.ts | 2 ++ contracts/package.json | 4 ++-- contracts/pnpm-lock.yaml | 12 ++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 55e6394..10e576b 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -9,6 +9,8 @@ tryLoadEnvFile(".env"); export default defineConfig({ plugins: [hardhatToolboxViem, hardhatViemAbi], codegen: { + // Keepers and the UI install `abi/` as this package name; do not rename casually. + packageJson: { name: "collateral-margin-abi" }, contracts: [ "CollateralVault", "ICollateralVault", diff --git a/contracts/package.json b/contracts/package.json index 6069764..e084762 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,7 +27,7 @@ "@types/node": "^22.0.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "hardhat": "^3.9.1", - "hardhat-viem-abi": "https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.1&path:packages/hardhat-viem-abi", + "hardhat-viem-abi": "https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.2&path:packages/hardhat-viem-abi", "typescript": "^5.8.0" }, "dependencies": { @@ -42,4 +42,4 @@ "hardhat-viem-abi" ] } -} \ No newline at end of file +} diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 8f9c3ae..b3a8f2a 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -43,8 +43,8 @@ importers: specifier: ^3.9.1 version: 3.9.1 hardhat-viem-abi: - specifier: https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.1&path:packages/hardhat-viem-abi - version: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + specifier: https://github.com/lsheva/hardhat-viem-abi.git#v1.0.0-alpha.2&path:packages/hardhat-viem-abi + version: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -754,9 +754,9 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi: - resolution: {path: packages/hardhat-viem-abi, tarball: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9} - version: 1.0.0-alpha.1 + 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} + version: 1.0.0-alpha.2 engines: {node: '>=22'} peerDependencies: hardhat: ^3.0.0 @@ -1706,7 +1706,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/194bf0c28cc1ef7a37e4d6aec024fbd191f5b8a9#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): dependencies: hardhat: 3.9.1 viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) From 88c05efe41ff46dce7db8e282e6abcee21ca19fe Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 6 Aug 2026 14:55:05 +0200 Subject: [PATCH 134/180] fix: deploy pme script --- .../scripts/deploy-portfolio-margin-engine.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/contracts/scripts/deploy-portfolio-margin-engine.ts b/contracts/scripts/deploy-portfolio-margin-engine.ts index 9168527..4792520 100644 --- a/contracts/scripts/deploy-portfolio-margin-engine.ts +++ b/contracts/scripts/deploy-portfolio-margin-engine.ts @@ -12,7 +12,7 @@ async function main() { const { viem } = await hre.network.getOrCreate(); - const vaultAddress = requireAddress("VAULT_ADDRESS"); + const VAULT_ADDRESS = requireAddress("VAULT_ADDRESS"); const SAFE_OWNER_ADDRESS = readOptionalAddress("SAFE_OWNER_ADDRESS"); const PERPS_ADDRESS = readOptionalAddress("PERPS_ADDRESS"); const OPTIONS_ENGINE_ADDRESS = readOptionalAddress("OPTIONS_ENGINE_ADDRESS"); @@ -34,7 +34,7 @@ async function main() { logInfo("deployer", { Address: addrUrl(pc, deployer.account.address) }); // ── Verify vault & whether deployer can wire it ───────────────────────── - const vault = await viem.getContractAt("CollateralVault", vaultAddress); + const vault = await viem.getContractAt("CollateralVault", VAULT_ADDRESS); const vaultOwner = await vault.read.owner(); const deployerIsVaultOwner = getAddress(vaultOwner) === getAddress(deployer.account.address); logInfo("vault", { @@ -83,17 +83,18 @@ async function main() { const pmeInitData = encodeFunctionData({ abi: pmeImpl.abi, functionName: "initialize", - args: [vault.address], + args: [], }); const pmeProxy = await viem.deployContract("ERC1967Proxy", [pmeImpl.address, pmeInitData], { confirmations: 5, }); + logStep("Deployed", addrUrl(pc, pmeProxy.address)); await verifyContract(pmeProxy.address, [pmeImpl.address, pmeInitData]); logStep("Verified", addrUrl(pc, pmeProxy.address)); const pme = await viem.getContractAt("PortfolioMarginEngine", pmeProxy.address); - logInfo("pme", { + const data = { Address: addrUrl(pc, pme.address), Version: await pme.read.VERSION(), Owner: await pme.read.owner(), @@ -101,7 +102,11 @@ async function main() { mmSpotShock: await pme.read.mmSpotShock(), imVolShock: await pme.read.imVolShock(), mmVolShock: await pme.read.mmVolShock(), - }); + vault: await pme.read.vault(), + } + + logInfo("pme", data); + // ── 3. Override stress shocks (optional) ──────────────────────────────── if (overrideShocks) { @@ -157,6 +162,14 @@ async function main() { logStep("Done", txUrl(pc, receipt.transactionHash)); } + if (getAddress(data.vault) !== getAddress(VAULT_ADDRESS)) { + logInfo("PME.setVault", { vault: VAULT_ADDRESS }); + await logPrompt("Proceed?"); + const sim = await pme.simulate.setVault([VAULT_ADDRESS]); + const receipt = await writeAndWait(deployer, sim); + logStep("Done", txUrl(pc, receipt.transactionHash)); + } + // ── 5. Wire the vault (owner only) ────────────────────────────────────── // The deployer can do this only when it is still the vault owner. Otherwise // we surface the calldata that the current owner (typically a Safe) must From 2c0167d40ea2b25bc31a19e7ac79bd0ae28918ec Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 6 Aug 2026 15:02:09 +0200 Subject: [PATCH 135/180] feat: inc version --- contracts/contracts/CollateralVault.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/contracts/CollateralVault.sol b/contracts/contracts/CollateralVault.sol index 1d0a0ce..ef46a0b 100644 --- a/contracts/contracts/CollateralVault.sol +++ b/contracts/contracts/CollateralVault.sol @@ -52,7 +52,7 @@ contract CollateralVault is ICollateralVault, UUPSUpgradeable, OwnableUpgradeabl /// Balance is normal vault receipt tokens; authorized callers credit it via /// `transfer` / `credit` / `depositFor`. Owner withdraws via `withdrawInsuranceFund`. address public constant INSURANCE_FUND_ADDR = 0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa; - string public constant VERSION = "1.0.1"; + string public constant VERSION = "1.1.0"; IERC20 public collateralToken; mapping(address => bool) public authorizedCallers; From 0783cea78a70d4db0ef3b0e092bf1eb342934713 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 6 Aug 2026 15:32:06 +0200 Subject: [PATCH 136/180] ci(keeper): point venue checkouts at the branches implementing this engine The pins predated the order API change, so integration tests were exercising the keeper against contracts without explicit time-in-force or getRiskView. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 268323e..7a74c37 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -110,16 +110,18 @@ jobs: uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - # reduceOrderSize + three-arg updateOrders (PR #80). - ref: 85ce3c88afe23d2c4b6e451f7de6447cac0eda6d + # createOrder with explicit TIF + getRiskView; implements this branch's + # engine interface (PR #90). + ref: 8df78601bbbad049cee962509f9c9e8b9d9b2503 path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - # reduceOrderSize + three-arg updateOrders (PR #227). - ref: a2115badfbd78206c172b0bd5cb47d697985bca9 + # createOrder with explicit TIF + getRiskView; implements this branch's + # engine interface (PR #247). + ref: 16d026960fa15d0fb73822e6750ae8e3f14a4c6d path: futures-marketplace - name: Install perps contracts dependencies From f9a1b959d12b50891b7c9417506b35e9b646eba7 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 00:12:21 +0200 Subject: [PATCH 137/180] fix(market-maker): resolve collateral token through vault Use the current venue ABI layout for both perps and futures collateral discovery, and refresh pinned contract packages. Co-authored-by: Cursor --- market-maker/package.json | 2 +- market-maker/pnpm-lock.yaml | 46 ++++++++++++------------ market-maker/src/adapters/perps/venue.ts | 14 ++++---- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/market-maker/package.json b/market-maker/package.json index 986427e..9e1e681 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -41,7 +41,7 @@ "fraction.js": "^5.2.2", "futures-contracts": "github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index caec371..dfddf4a 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 1.1.9 collateral-margin-contracts: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts(typescript@5.9.3) fraction.js: specifier: ^5.2.2 version: 5.3.4 @@ -33,8 +33,8 @@ importers: specifier: ^4.1.0 version: 4.1.1 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#85ce3c88afe23d2c4b6e451f7de6447cac0eda6d&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#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)) + specifier: github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts + version: 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)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -459,10 +459,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} @@ -1244,13 +1240,13 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a} + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd} version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} + 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} version: 1.0.0 collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: @@ -1340,8 +1336,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d} + 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} version: 1.0.0 engines: {node: 24.x} @@ -1921,6 +1917,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: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3548,8 +3548,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': @@ -4532,19 +4530,19 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3): + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts(typescript@5.9.3): dependencies: '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) dotenv: 16.6.1 - viem: 2.48.4(typescript@5.9.3) + viem: 2.55.5(typescript@5.9.3) transitivePeerDependencies: - bufferutil - typescript - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6: {} collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: {} @@ -4629,17 +4627,17 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/85ce3c88afe23d2c4b6e451f7de6447cac0eda6d#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)): + 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/a7f89b8fd167655d1157568230bc6779ce37d512 + 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' @@ -4924,7 +4922,7 @@ snapshots: '@safe-global/types-kit': 4.0.1(typescript@5.9.3) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b - 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 viem: 2.55.5(typescript@5.9.3) transitivePeerDependencies: @@ -5347,6 +5345,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: {} diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 2c00cf1..c1586f3 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -216,7 +216,7 @@ export class PerpsVenueAdapter implements VenueAdapter { token: this.collateralTokenCache, }; } - const [vault, engine, token] = await this.publicClient.multicall({ + const [vault, engine] = await this.publicClient.multicall({ allowFailure: false, contracts: [ { @@ -229,13 +229,15 @@ export class PerpsVenueAdapter implements VenueAdapter { abi: HashPowerPerpsDEXAbi, functionName: "portfolioMargin", }, - { - address: this.address, - abi: HashPowerPerpsDEXAbi, - functionName: "collateralToken", - }, ], }); + // The current perps implementation exposes the shared vault, while the + // collateral token is a getter on the vault itself. + const token = await this.publicClient.readContract({ + address: vault, + abi: CollateralVaultAbi, + functionName: "collateralToken", + }); this.vaultAddressCache = vault; this.engineAddressCache = engine; this.collateralTokenCache = token; From f3c91b559ab01d2469eacc0f0e60f86b058c05a2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 00:24:03 +0200 Subject: [PATCH 138/180] fix(keeper): remove obsolete Docker patch copy Co-authored-by: Cursor --- keeper/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/keeper/Dockerfile b/keeper/Dockerfile index 51323f6..5237901 100644 --- a/keeper/Dockerfile +++ b/keeper/Dockerfile @@ -6,7 +6,6 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY patches/ patches/ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── From 659f0a60ad9491bd36985c537f0049010164e338 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 13:23:02 +0200 Subject: [PATCH 139/180] fix(market-maker): adapt to current venue ABIs Use the Git-pinned Futures ABI, resolve vault and portfolio margin through the current getters, derive oracle scaling from Chainlink decimals, and update order simulation signatures. Co-authored-by: Cursor --- market-maker/package.json | 2 +- market-maker/pnpm-lock.yaml | 51 +++++++------------ .../src/adapters/futures/instrument.ts | 4 +- market-maker/src/adapters/futures/venue.ts | 40 ++++++++------- market-maker/src/adapters/perps/instrument.ts | 2 +- 5 files changed, 45 insertions(+), 54 deletions(-) diff --git a/market-maker/package.json b/market-maker/package.json index 9e1e681..6963482 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -39,7 +39,7 @@ "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#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts", + "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", diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index dfddf4a..6457c1c 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -22,13 +22,13 @@ importers: version: 1.1.9 collateral-margin-contracts: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts(typescript@5.9.3) + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts(typescript@5.9.3) fraction.js: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#a2115badfbd78206c172b0bd5cb47d697985bca9&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + 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,6 +459,10 @@ 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} @@ -1240,8 +1244,8 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd} + 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} version: 1.0.0 engines: {node: 24.x} @@ -1249,10 +1253,6 @@ packages: resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6} version: 1.0.0 - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9} - version: 1.0.0 - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1550,8 +1550,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9} + 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} @@ -1632,10 +1632,6 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b} - version: 1.0.0 - 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} version: 1.0.0 @@ -1917,10 +1913,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - 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 - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -3548,6 +3540,8 @@ 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': @@ -4530,7 +4524,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/8d50711ee9a46ccb96172e6c2ac9df63a1b376bd#path:/contracts(typescript@5.9.3): + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts(typescript@5.9.3): dependencies: '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) @@ -4544,8 +4538,6 @@ snapshots: collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6: {} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4630,14 +4622,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': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/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: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4911,7 +4903,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a2115badfbd78206c172b0bd5cb47d697985bca9#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + 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 @@ -4920,9 +4912,8 @@ snapshots: '@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: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ce0b494f3050b96deb4a400f099c53086f2273b9 - hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b - multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + 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: @@ -5065,8 +5056,6 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/2f570d8fd5ba21707d7e4047ead0a64868523b6b: {} - hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: {} hasown@2.0.3: @@ -5345,8 +5334,6 @@ 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: {} diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 5ad7e11..c43151d 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -299,8 +299,8 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: FuturesAbi, functionName: "createOrder", - // Futures 3.0: createOrder(price, expirationAt, signedQuantity) - args: [1_000_000n, this.expirationAt, 1n], + // Futures 3.0: createOrder(price, expirationAt, signedQuantity, timeInForce) + args: [1_000_000n, this.expirationAt, 1n, 0], account, }); } catch { diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index faea3c3..f232b50 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -17,7 +17,7 @@ import { CollateralVaultAbi } from "collateral-margin-contracts/abi/CollateralVa import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; -import { RawOracleReader } from "../../core/rawOracle.ts"; +import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; import { FuturesVenueEvents } from "./events.ts"; @@ -119,29 +119,33 @@ export class FuturesVenueAdapter implements VenueAdapter { this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); - // Discover (oracle, divisor) from the futures contract on first read. - // The divisor is precomputed on chain (`hashpriceScalingDivisor`), so we - // just fetch both fields together. + // Discover the Chainlink oracle and derive the decimal rebase on first read. this.rawOracle = new RawOracleReader({ publicClient: this.publicClient, label: "futures", resolve: async () => { - const [oracle, divisor] = await this.publicClient.multicall({ + const { token } = await this.resolveAddresses(); + const oracle = await this.publicClient.readContract({ + address: this.address, + abi: FuturesAbi, + functionName: "priceOracle", + }); + const [oracleDecimals, tokenDecimals] = await this.publicClient.multicall({ allowFailure: false, contracts: [ - { - address: this.address, - abi: FuturesAbi, - functionName: "hashrateOracle", - }, - { - address: this.address, - abi: FuturesAbi, - functionName: "hashpriceScalingDivisor", - }, + { address: oracle, abi: chainlinkAggregatorAbi, functionName: "decimals" }, + { address: token, abi: erc20Abi, functionName: "decimals" }, ], }); - return { oracle, divisor }; + if (tokenDecimals > oracleDecimals) { + throw new Error( + `futures: tokenDecimals (${tokenDecimals}) > oracleDecimals (${oracleDecimals})`, + ); + } + return { + oracle, + divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + }; }, }); } @@ -310,12 +314,12 @@ export class FuturesVenueAdapter implements VenueAdapter { { address: this.address, abi: FuturesAbi, - functionName: "collateralVault", + functionName: "vault", }, { address: this.address, abi: FuturesAbi, - functionName: "marginEngine", + functionName: "portfolioMargin", }, ], }); diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index de018fe..3486895 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -271,7 +271,7 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "createOrder", - args: [1_000_000n, 1_000_000n], + args: [1_000_000n, 1_000_000n, 0], account, }); } catch { From f78886f97963a825509913c5ae22e386a2351d41 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 13:29:50 +0200 Subject: [PATCH 140/180] chore(keeper): refresh contract ABI dependencies Pin the keeper's vault, venue, and portfolio-margin packages to the current Git revisions used by the market maker. Co-authored-by: Cursor --- keeper/package.json | 8 ++++---- keeper/pnpm-lock.yaml | 40 ++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index a440c98..ea42073 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,11 +21,11 @@ "lint:fix": "biome check --write ." }, "dependencies": { - "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/portfolio-margin", + "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/portfolio-margin", "amaro": "^1.1.9", - "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#4f068e7bda814a9987fcfd2dd860fdacda7751c0&path:/contracts/abi", + "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", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index d456fdd..d96ef49 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: dependencies: '@hashpower/portfolio-margin': - specifier: github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/portfolio-margin - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin + specifier: github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/portfolio-margin + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin amaro: specifier: ^1.1.9 version: 1.1.10 collateral-margin-abi: - specifier: github:Lumerin-protocol/collateral-margin#4846d1e293561554060d690746e14161750e0cf4&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi + 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#6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/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#4f068e7bda814a9987fcfd2dd860fdacda7751c0&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/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 pino: specifier: ^10.3.1 version: 10.3.1 @@ -105,8 +105,8 @@ packages: cpu: [x64] os: [win32] - '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin': - resolution: {gitHosted: true, path: /portfolio-margin, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4} + '@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} version: 0.1.0 engines: {node: '>=22'} @@ -203,8 +203,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4} + 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} version: 0.0.0 colorette@2.0.20: @@ -213,8 +213,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/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a} + 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} version: 0.0.0 end-of-stream@1.4.5: @@ -229,8 +229,8 @@ 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/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0} + 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: @@ -380,7 +380,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.13': optional: true - '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/portfolio-margin': {} + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin': {} '@noble/ciphers@1.3.0': {} @@ -446,13 +446,13 @@ snapshots: atomic-sleep@1.0.0: {} - collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4846d1e293561554060d690746e14161750e0cf4#path:/contracts/abi: {} + collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts/abi: {} colorette@2.0.20: {} dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/6f9e653a4fef16b129b6d3ec6bc3e7d70817e20a#path:/contracts/abi: {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi: {} end-of-stream@1.4.5: dependencies: @@ -464,7 +464,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/4f068e7bda814a9987fcfd2dd860fdacda7751c0#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: {} help-me@5.0.0: {} From 18571f9c0877f23456c271531aa31e0c1426dcae Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 21:40:08 +0200 Subject: [PATCH 141/180] refactor(keeper): consume unified order aggregates Use the published struct-returning venue ABIs so predictive snapshots no longer depend on the removed value-only compatibility getter. Co-authored-by: Cursor --- keeper/package.json | 4 ++-- keeper/pnpm-lock.yaml | 20 ++++++++-------- keeper/src/predict/snapshot.ts | 24 +++++++++---------- keeper/tests/predict/coordinator.test.ts | 4 ++-- .../tests/predict/coordinatorAlerts.test.ts | 4 ++-- keeper/tests/predict/snapshot.test.ts | 11 ++++++--- keeper/tests/venues/reduceToTarget.test.ts | 2 +- portfolio-margin/src/types.ts | 6 ++--- 8 files changed, 40 insertions(+), 35 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index ea42073..cd88e6a 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -24,8 +24,8 @@ "@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", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index d96ef49..def7113 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -18,11 +18,11 @@ 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 + 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 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/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -213,8 +213,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, 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,8 +229,8 @@ 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} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303} version: 0.0.0 help-me@5.0.0: @@ -452,7 +452,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,7 +464,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 718e9ad..db630ca 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -63,7 +63,7 @@ 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`, + * 1. Bulk multicall: balance, both venues' `getRiskView` / `getOrderAggregate`, * 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 @@ -75,7 +75,7 @@ export async function readMMParams( * * `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 `getOrderAggregate` and the predictor derives * fill loss at whatever price it is evaluating. Pending funding also rides in * `getRiskView`, replacing the separate `getPendingFunding` read. */ @@ -88,9 +88,9 @@ export async function readAccountSnapshot( balance, perpPosition, perpRisk, - perpOrderValues, + perpOrderAggregate, futuresRisk, - futuresOrderValues, + futuresOrderAggregate, activeExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ @@ -115,7 +115,7 @@ export async function readAccountSnapshot( { address: config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "getOrderValues" as const, + functionName: "getOrderAggregate" as const, args: [user] as const, }, { @@ -127,7 +127,7 @@ export async function readAccountSnapshot( { address: config.futures.address, abi: FuturesAbi, - functionName: "getOrderValues" as const, + functionName: "getOrderAggregate" as const, args: [user] as const, }, { @@ -182,26 +182,26 @@ export async function readAccountSnapshot( perp: { netQty: perpPosition.netQuantity, entryPrice: perpPosition.aggregatedEntryPrice, - orders: restingOrders(perpRisk, perpOrderValues), + 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, }; } diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index be7f4f3..53c0a63 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -129,8 +129,8 @@ function buildHarness({ buyOrderFillLoss: 0n, sellOrderFillLoss: 0n, }; - case "getOrderValues": - return [0n, 0n]; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index bfaad08..10529c8 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -104,8 +104,8 @@ 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 "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index 243fc6a..efe87df 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -90,10 +90,15 @@ function makeChain(scripted: { sellOrderFillLoss: 0n, }; } - case "getOrderValues": { + case "getOrderAggregate": { const orders = (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; - return [orders.buyValue, orders.sellValue]; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: orders.buyValue, + sellValue: orders.sellValue, + }; } case "getActiveExpirationDates": return scripted.activeExpirationAts ?? []; @@ -137,7 +142,7 @@ describe("predict/snapshot: readAccountSnapshot", () => { 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, diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index bc70084..911cac4 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -31,7 +31,7 @@ const EMPTY_RISK_VIEW = { /** * The bulk read `readAccountSnapshot` issues, in order: balance, the perp position, - * then each venue's `getRiskView` / `getOrderValues` pair, then the active futures + * then each venue's `getRiskView` / `getOrderAggregate` pair, then the active futures * expiries. Only the expiry list varies between these cases. */ function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { 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 From 4f1a1c531f59c83d08c86a4caaaa4f80e436dcbc Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Fri, 7 Aug 2026 23:40:20 +0200 Subject: [PATCH 142/180] refactor: use typed contract batch methods Route keeper maintenance and settlement through dedicated contract methods and remove unused market-maker multicall wrappers. Co-authored-by: Cursor --- keeper/src/config.ts | 8 +- keeper/src/delivery/coordinator.ts | 38 ++--- keeper/src/runtime/outdatedOrderSweeper.ts | 114 ++++----------- keeper/tests/delivery/coordinator.test.ts | 21 ++- keeper/tests/integration/buildKeeper.ts | 2 +- .../runtime/outdatedOrderSweeper.test.ts | 133 ++---------------- market-maker/src/adapters/futures/venue.ts | 30 ---- market-maker/src/adapters/perps/venue.ts | 30 ---- market-maker/src/core/adapter.ts | 11 +- market-maker/tests/core/errSerializer.test.ts | 4 +- 10 files changed, 78 insertions(+), 313 deletions(-) diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 71eb100..520afcc 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -177,8 +177,8 @@ export interface Config { */ sweepIntervalMs: number; /** - * Maximum number of `removeOutdatedOrder` calls bundled into a - * single `Futures.multicall(bytes[])` tx. Each call is roughly + * Maximum number of ids passed to one + * `Futures.removeOutdatedOrders(bytes32[])` tx. Each cleanup 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. @@ -221,8 +221,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 diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index ceaa3ae..bba247b 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, @@ -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 @@ -466,8 +448,8 @@ export class DeliveryCoordinator { this.chain.walletClient.writeContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], + 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); } } diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts index 8043fe2..126060c 100644 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -1,10 +1,4 @@ -import { - BaseError, - ContractFunctionRevertedError, - encodeFunctionData, - type Address, - type Hex, -} from "viem"; +import { 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"; @@ -39,20 +33,26 @@ import type { ParticipantTracker } from "../discovery/tracker.ts"; * 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 + * 3. one `Futures.removeOutdatedOrders([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. + * The typed batch skips stale and not-yet-expired ids on-chain, so a user + * cancellation or competing keeper cannot revert unrelated cleanup work. * * Non-futures venues (perps) don't have order expiry so this module is * Futures-only by design. */ -const RECOVERABLE_REVERTS = new Set(["OrderNotExists", "OrderNotExpired"]); +const FUTURES_REMOVE_OUTDATED_ORDERS_ABI = [ + { + type: "function", + name: "removeOutdatedOrders", + stateMutability: "nonpayable", + inputs: [{ name: "_orderIds", type: "bytes32[]" }], + outputs: [{ name: "removed", type: "uint256" }], + }, +] as const; interface ExpiredOrder { user: Address; @@ -251,72 +251,21 @@ export class OutdatedOrderSweeper { } /** - * 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. + * Sends one race-tolerant `removeOutdatedOrders(ids)` write. The contract + * skips stale/live ids and preserves every valid cleanup in the batch. */ 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 (batch.length === 0) return 0; if (this.config.keeper.dryRun) { this.logger.info( - { batchSize: survivors.length }, - "[dryRun] would call Futures.multicall(removeOutdatedOrder × N)", + { batchSize: batch.length }, + "[dryRun] would call Futures.removeOutdatedOrders", ); return 0; } - const calldatas: Hex[] = survivors.map((entry) => - encodeFunctionData({ - abi: FuturesAbi, - functionName: "removeOutdatedOrder", - args: [entry.orderId], - }), - ); + const orderIds = batch.map((entry) => entry.orderId); type WriteParams = Parameters< typeof this.chain.walletClient.writeContract @@ -330,9 +279,9 @@ export class OutdatedOrderSweeper { hash = await withUnstickRetry(this.chain, this.logger, () => this.chain.walletClient.writeContract({ address: this.config.futures.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], + abi: FUTURES_REMOVE_OUTDATED_ORDERS_ABI, + functionName: "removeOutdatedOrders", + args: [orderIds], account: this.chain.account, chain: this.chain.walletClient.chain ?? null, } as unknown as WriteParams), @@ -342,7 +291,7 @@ export class OutdatedOrderSweeper { // want unhandled rejection on the setInterval-fired path to crash // the keeper, so always swallow and log. this.logger.warn( - { err, batchSize: survivors.length }, + { err, batchSize: batch.length }, "tx submission failed — sweep will retry", ); return 0; @@ -356,22 +305,11 @@ export class OutdatedOrderSweeper { { hash, blockNumber: receipt.blockNumber.toString(), - batchSize: survivors.length, + batchSize: batch.length, ...formatGasCost(receipt, this.ethUsdFeed), }, - "multicall(removeOutdatedOrder × N) confirmed", + "removeOutdatedOrders confirmed", ); - return survivors.length; + return batch.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/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/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 2da307e..ae84766 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). diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index ebdb98d..a051584 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -1,14 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - BaseError, - ContractFunctionRevertedError, - decodeFunctionData, - type Address, - type Hex, -} from "viem"; +import { 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"; @@ -60,22 +53,18 @@ 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[] }>; + writeCalls: Array<{ functionName: string; orderIds: Hex[] }>; } function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { const recorded: Recorded = { readContractCalls: 0, multicallReadCalls: 0, - simulateCalls: [], writeCalls: [], }; @@ -121,24 +110,6 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { }; }); }, - 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, @@ -155,10 +126,10 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { functionName: string; args: unknown[]; }) => { - if (functionName !== "multicall") { + if (functionName !== "removeOutdatedOrders") { throw new Error(`unexpected write fn: ${functionName}`); } - recorded.writeCalls.push({ functionName, calldatas: args[0] as Hex[] }); + recorded.writeCalls.push({ functionName, orderIds: args[0] as Hex[] }); return "0xabc" as Hex; }, }; @@ -171,20 +142,6 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { 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 }, @@ -260,11 +217,10 @@ describe("OutdatedOrderSweeper", () => { ); 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 () => { + it("batches all expired orders for a user into one typed write", async () => { const { logger, calls } = makeRecordingLogger(); const id1 = ("0x" + "11".repeat(32)) as Hex; const id2 = ("0x" + "22".repeat(32)) as Hex; @@ -289,21 +245,12 @@ describe("OutdatedOrderSweeper", () => { 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.equal(recorded.writeCalls[0]!.functionName, "removeOutdatedOrders"); + assert.deepEqual(recorded.writeCalls[0]!.orderIds, [id1, id2]); assert.ok( calls.some((c) => c.level === "info" && c.msg.includes("confirmed")), - "expected an INFO log when the multicall write confirms", + "expected an INFO log when the batch write confirms", ); }); @@ -333,9 +280,9 @@ describe("OutdatedOrderSweeper", () => { assert.equal( recorded.writeCalls.length, 1, - "one multicall write for cross-user batch", + "one typed write for cross-user batch", ); - assert.equal(recorded.writeCalls[0]!.calldatas.length, 2); + assert.deepEqual(recorded.writeCalls[0]!.orderIds, [idA, idB]); }); it("splits across multiple writes when batch size cap is exceeded", async () => { @@ -363,54 +310,12 @@ describe("OutdatedOrderSweeper", () => { // 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), + recorded.writeCalls.map((c) => c.orderIds.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 () => { + it("skips the write entirely on dry-run", async () => { const { logger, calls } = makeRecordingLogger(); const id1 = ("0x" + "11".repeat(32)) as Hex; const { chain, recorded } = makeChain({ @@ -429,11 +334,6 @@ describe("OutdatedOrderSweeper", () => { 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]"))); }); @@ -451,7 +351,6 @@ describe("OutdatedOrderSweeper", () => { const recorded: Recorded = { readContractCalls: 0, multicallReadCalls: 0, - simulateCalls: [], writeCalls: [], }; const publicClient = { @@ -479,10 +378,6 @@ describe("OutdatedOrderSweeper", () => { }; }); }, - simulateContract: async ({ args }: { args: unknown[] }) => { - recorded.simulateCalls.push(args[0] as Hex); - return { request: {} }; - }, waitForTransactionReceipt: async () => ({ blockNumber: 1n, gasUsed: 0n, @@ -493,8 +388,8 @@ describe("OutdatedOrderSweeper", () => { chain: null, writeContract: async ({ args }: { args: unknown[] }) => { recorded.writeCalls.push({ - functionName: "multicall", - calldatas: args[0] as Hex[], + functionName: "removeOutdatedOrders", + orderIds: args[0] as Hex[], }); return "0xabc" as Hex; }, diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index f232b50..ec4c86a 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -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<{ 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/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"); }); From f807ff72fe7ad2f5e372a83a43e40e872907e3fc Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 8 Aug 2026 00:46:46 +0200 Subject: [PATCH 143/180] refactor: retire expired-order sweeper Expired futures orders no longer consume global limits, so remove scheduled cleanup and scope market-maker reads to each delivery. Co-authored-by: Cursor --- keeper/src/config.ts | 23 - keeper/src/index.ts | 18 - keeper/src/runtime/outdatedOrderSweeper.ts | 315 ----------- keeper/tests/integration/buildKeeper.ts | 7 - keeper/tests/runtime/healthcheck.test.ts | 4 - .../runtime/outdatedOrderSweeper.test.ts | 510 ------------------ .../src/adapters/futures/ownOrders.ts | 28 +- 7 files changed, 19 insertions(+), 886 deletions(-) delete mode 100644 keeper/src/runtime/outdatedOrderSweeper.ts delete mode 100644 keeper/tests/runtime/outdatedOrderSweeper.test.ts diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 520afcc..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 ids passed to one - * `Futures.removeOutdatedOrders(bytes32[])` tx. Each cleanup 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 @@ -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/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/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts deleted file mode 100644 index 126060c..0000000 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { 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.removeOutdatedOrders([id1, ...])` write - * (capped at `outdatedOrders.maxBatchSize`; larger user-side - * fan-outs are split into N batches, each its own tx). - * - * The typed batch skips stale and not-yet-expired ids on-chain, so a user - * cancellation or competing keeper cannot revert unrelated cleanup work. - * - * Non-futures venues (perps) don't have order expiry so this module is - * Futures-only by design. - */ - -const FUTURES_REMOVE_OUTDATED_ORDERS_ABI = [ - { - type: "function", - name: "removeOutdatedOrders", - stateMutability: "nonpayable", - inputs: [{ name: "_orderIds", type: "bytes32[]" }], - outputs: [{ name: "removed", type: "uint256" }], - }, -] as const; - -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; - } - - /** - * Sends one race-tolerant `removeOutdatedOrders(ids)` write. The contract - * skips stale/live ids and preserves every valid cleanup in the batch. - */ - private async closeBatch(batch: readonly ExpiredOrder[]): Promise { - if (batch.length === 0) return 0; - - if (this.config.keeper.dryRun) { - this.logger.info( - { batchSize: batch.length }, - "[dryRun] would call Futures.removeOutdatedOrders", - ); - return 0; - } - - const orderIds = batch.map((entry) => 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: FUTURES_REMOVE_OUTDATED_ORDERS_ABI, - functionName: "removeOutdatedOrders", - args: [orderIds], - 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: batch.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: batch.length, - ...formatGasCost(receipt, this.ethUsdFeed), - }, - "removeOutdatedOrders confirmed", - ); - return batch.length; - } -} diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index ae84766..26983cb 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -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/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 a051584..0000000 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { type Address, type Hex } from "viem"; -import pino from "pino"; -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; -} - -interface Recorded { - readContractCalls: number; - multicallReadCalls: number; - writeCalls: Array<{ functionName: string; orderIds: Hex[] }>; -} - -function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { - const recorded: Recorded = { - readContractCalls: 0, - multicallReadCalls: 0, - 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, - }; - }); - }, - waitForTransactionReceipt: async () => ({ - blockNumber: 1n, - gasUsed: 200_000n, - logs: [], - }), - }; - - const walletClient = { - chain: null, - writeContract: async ({ - functionName, - args, - }: { - functionName: string; - args: unknown[]; - }) => { - if (functionName !== "removeOutdatedOrders") { - throw new Error(`unexpected write fn: ${functionName}`); - } - recorded.writeCalls.push({ functionName, orderIds: args[0] as Hex[] }); - return "0xabc" as Hex; - }, - }; - - const chain: Chain = { - publicClient, - walletClient, - account: { address: SIGNER }, - } as unknown as Chain; - return { chain, recorded }; -} - -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.writeCalls.length, 0); - }); - - it("batches all expired orders for a user into one typed 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.equal(recorded.writeCalls[0]!.functionName, "removeOutdatedOrders"); - assert.deepEqual(recorded.writeCalls[0]!.orderIds, [id1, id2]); - - assert.ok( - calls.some((c) => c.level === "info" && c.msg.includes("confirmed")), - "expected an INFO log when the batch 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 typed write for cross-user batch", - ); - assert.deepEqual(recorded.writeCalls[0]!.orderIds, [idA, idB]); - }); - - 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.orderIds.length), - [2, 2, 1], - ); - }); - - it("skips the write entirely on dry-run", 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.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, - 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, - }; - }); - }, - waitForTransactionReceipt: async () => ({ - blockNumber: 1n, - gasUsed: 0n, - logs: [], - }), - }; - const walletClient = { - chain: null, - writeContract: async ({ args }: { args: unknown[] }) => { - recorded.writeCalls.push({ - functionName: "removeOutdatedOrders", - orderIds: 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/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 9c31311..1ae7421 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -10,16 +10,26 @@ 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) { @@ -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; From 890fc7c18206967aceb468a970d11e1fa6fe5c62 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 8 Aug 2026 02:42:50 +0200 Subject: [PATCH 144/180] perf(pme): specialize resting order checks Let markets answer portfolio liquidation order gates from their aggregate indexes without computing full risk views or reading price oracles. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 7 +++---- contracts/contracts/interfaces/ILinearMarket.sol | 5 +++++ contracts/contracts/mocks/FuturesMock.sol | 4 ++++ contracts/contracts/mocks/PerpsDEXMock.sol | 10 ++++++++++ contracts/tests/portfolioMarginEngine.test.ts | 11 +++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 6fac367..c01ecfc 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -310,13 +310,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; } 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/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/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index 1f89a35..a05b1c3 100644 --- a/contracts/contracts/mocks/PerpsDEXMock.sol +++ b/contracts/contracts/mocks/PerpsDEXMock.sol @@ -31,6 +31,7 @@ contract PerpsDEXMock is ILinearMarket { 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); @@ -60,6 +61,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,6 +97,14 @@ contract PerpsDEXMock is ILinearMarket { _sellOrderFillLoss[user] = sellLoss; } + function setRiskViewDisabled(bool disabled) external { + _riskViewDisabled = disabled; + } + + function hasRestingOrderDelta(address user) external view returns (bool) { + return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; + } + function isLiquidatable(address user) external view returns (bool) { if (_positions[user].netQuantity == 0) return false; return _balances[user] < _maintenanceMargin[user]; diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index ed2bb2b..46f98ed 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -279,6 +279,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, From 2bf1d055c27697c3dde6884ec57f629a37a9d2cf Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 8 Aug 2026 02:50:38 +0200 Subject: [PATCH 145/180] perf(pme): compute liquidation margins together Price IM and MM from one market/options snapshot and oracle read so post-liquidation guards avoid duplicating every portfolio call. Co-authored-by: Cursor --- contracts/abi/IPortfolioMarginEngine.json | 24 ++++++ contracts/abi/IPortfolioMarginEngine.ts | 24 ++++++ contracts/abi/PortfolioMarginEngine.json | 24 ++++++ contracts/abi/PortfolioMarginEngine.ts | 24 ++++++ contracts/contracts/PortfolioMarginEngine.sol | 82 +++++++++++++------ .../interfaces/IPortfolioMarginEngine.sol | 3 + .../contracts/mocks/MarginEngineMock.sol | 4 + contracts/tests/portfolioMarginEngine.test.ts | 2 + 8 files changed, 161 insertions(+), 26 deletions(-) diff --git a/contracts/abi/IPortfolioMarginEngine.json b/contracts/abi/IPortfolioMarginEngine.json index bb87587..4be2dfc 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": [ { diff --git a/contracts/abi/IPortfolioMarginEngine.ts b/contracts/abi/IPortfolioMarginEngine.ts index 2b93977..a9a1c57 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": [ { diff --git a/contracts/abi/PortfolioMarginEngine.json b/contracts/abi/PortfolioMarginEngine.json index 54d4ff2..a5dc6b5 100644 --- a/contracts/abi/PortfolioMarginEngine.json +++ b/contracts/abi/PortfolioMarginEngine.json @@ -361,6 +361,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", diff --git a/contracts/abi/PortfolioMarginEngine.ts b/contracts/abi/PortfolioMarginEngine.ts index 3bb317b..1461c08 100644 --- a/contracts/abi/PortfolioMarginEngine.ts +++ b/contracts/abi/PortfolioMarginEngine.ts @@ -361,6 +361,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", diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index c01ecfc..1633592 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -252,6 +252,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 @@ -291,12 +299,14 @@ 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); + 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; } @@ -335,37 +345,58 @@ contract PortfolioMarginEngine is uint256 fundingOwed; } + struct MarginInputs { + LinearAggregate linear; + int256 netDelta; + uint256 netGamma; + uint256 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); + 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 @@ -378,7 +409,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 @@ -408,7 +439,7 @@ contract PortfolioMarginEngine is /// @dev Evaluate 4 stress scenarios and return the worst-case loss (WAD). /// Scenarios: (±Δs, ±Δσ) where Δs = spotShock * spotPrice (dollar move) /// PnL ≈ delta·Δs + ½·gamma·Δs² + vega·Δσ - function _worstStressLoss(int256 netDelta, uint256 netGamma, uint256 netVega, bool isIM) + function _worstStressLoss(int256 netDelta, uint256 netGamma, uint256 netVega, bool isIM, uint256 spotPrice) private view returns (uint256 worst) @@ -417,7 +448,6 @@ 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² diff --git a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol index 4cc73cf..55a822f 100644 --- a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol +++ b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol @@ -19,6 +19,9 @@ 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 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/MarginEngineMock.sol b/contracts/contracts/mocks/MarginEngineMock.sol index c66df2d..ef888d2 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; diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index 46f98ed..d2507f0 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -398,8 +398,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"); }); }); From c0ecd98f555f0405bf23f32b87becd4e26b39112 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 8 Aug 2026 23:44:44 +0200 Subject: [PATCH 146/180] fix(pme): fail closed on invalid spot prices Prevent stale or non-positive oracle answers from erasing portfolio stress requirements during critical margin checks. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 10 +++--- contracts/tests/fixtures.ts | 4 +-- .../tests/gas-portfolioMarginEngine.test.ts | 32 +++++++++++++++++++ contracts/tests/portfolioMarginEngine.test.ts | 31 ++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 contracts/tests/gas-portfolioMarginEngine.test.ts diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 1633592..0badda9 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -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 @@ -524,14 +525,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); } 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..a50f579 --- /dev/null +++ b/contracts/tests/gas-portfolioMarginEngine.test.ts @@ -0,0 +1,32 @@ +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, 0n, 0n]); + + 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); + }); +}); diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index d2507f0..7d12a48 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -632,6 +632,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( From b445276ae4de4141ec63e2e61e1da3cf954aafb2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sat, 8 Aug 2026 23:47:46 +0200 Subject: [PATCH 147/180] fix(pme): preserve signed option convexity Stress signed net gamma and vega so short option exposure cannot be interpreted as protective long convexity. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 21 +++++++++---------- .../IOptionsEnginePortfolioView.sol | 2 +- .../contracts/mocks/OptionsEngineMock.sol | 8 +++---- .../tests/gas-portfolioMarginEngine.test.ts | 7 ++++++- contracts/tests/portfolioMarginEngine.test.ts | 18 ++++++++++++---- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 0badda9..dcbf9ad 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -349,8 +349,8 @@ contract PortfolioMarginEngine is struct MarginInputs { LinearAggregate linear; int256 netDelta; - uint256 netGamma; - uint256 netVega; + int256 netGamma; + int256 netVega; uint256 optionsReserved; } @@ -367,7 +367,7 @@ contract PortfolioMarginEngine is inputs.linear = agg; inputs.netDelta = agg.netDelta; if (address(optionsEngine) != address(0)) { - (int256 optDelta, uint256 optGamma, uint256 optVega) = optionsEngine.getNetGreeks(user); + (int256 optDelta, int256 optGamma, int256 optVega) = optionsEngine.getNetGreeks(user); inputs.netDelta += optDelta; inputs.netGamma = optGamma; inputs.netVega = optVega; @@ -440,7 +440,7 @@ contract PortfolioMarginEngine is /// @dev Evaluate 4 stress scenarios and return the worst-case loss (WAD). /// Scenarios: (±Δs, ±Δσ) where Δs = spotShock * spotPrice (dollar move) /// PnL ≈ delta·Δs + ½·gamma·Δs² + vega·Δσ - function _worstStressLoss(int256 netDelta, uint256 netGamma, uint256 netVega, bool isIM, uint256 spotPrice) + function _worstStressLoss(int256 netDelta, int256 netGamma, int256 netVega, bool isIM, uint256 spotPrice) private view returns (uint256 worst) @@ -452,7 +452,7 @@ contract PortfolioMarginEngine is 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)); @@ -474,16 +474,15 @@ contract PortfolioMarginEngine is /// 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) + function _scenarioLoss(int256 netDelta, int256 gammaTerm, int256 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; + int256 vegaPnl = netVega * deltaVol / int256(WAD); + // Gamma term is ½γ(Δs)²: positive gamma profits from moves, negative gamma loses. + int256 pnl = deltaPnl + gammaTerm + vegaPnl; return pnl < 0 ? uint256(-pnl) : 0; } @@ -577,7 +576,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/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/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/tests/gas-portfolioMarginEngine.test.ts b/contracts/tests/gas-portfolioMarginEngine.test.ts index a50f579..f1d6f49 100644 --- a/contracts/tests/gas-portfolioMarginEngine.test.ts +++ b/contracts/tests/gas-portfolioMarginEngine.test.ts @@ -14,7 +14,12 @@ describe("Gas: PortfolioMarginEngine", () => { 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, 0n, 0n]); + 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({ diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index 7d12a48..964a8a9 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -676,14 +676,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 () => { @@ -697,5 +696,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"); + }); }); }); From 8661576df4081fe37415e6f7a6511a09f705c160 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sun, 9 Aug 2026 01:32:50 +0200 Subject: [PATCH 148/180] perf(pme): fast-return zero order margin Skip options and oracle stress reads when linear markets report no resting-order exposure. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 1 + .../tests/gas-portfolioMarginEngine.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index dcbf9ad..22cc9e8 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -300,6 +300,7 @@ contract PortfolioMarginEngine is /// wanting a per-order gate want `linearOrderMargin` instead. function orderMarginOf(address user) external view returns (uint256) { LinearAggregate memory agg = _linearAggregate(user); + 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); diff --git a/contracts/tests/gas-portfolioMarginEngine.test.ts b/contracts/tests/gas-portfolioMarginEngine.test.ts index f1d6f49..eb68752 100644 --- a/contracts/tests/gas-portfolioMarginEngine.test.ts +++ b/contracts/tests/gas-portfolioMarginEngine.test.ts @@ -34,4 +34,31 @@ describe("Gas: PortfolioMarginEngine", () => { 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); + }); }); From 863a4e66e9ca929e8a8e434c1c436c1880256e75 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Sun, 9 Aug 2026 01:34:43 +0200 Subject: [PATCH 149/180] perf(pme): collapse equivalent stress scenarios Compute the minimum signed delta and vega endpoints directly now that both option Greeks preserve direction. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 40 ++++--------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 22cc9e8..8776533 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -438,8 +438,8 @@ 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, int256 netGamma, int256 netVega, bool isIM, uint256 spotPrice) private @@ -455,36 +455,12 @@ contract PortfolioMarginEngine is // Pre-compute gamma term: ½ · gamma · Δs² 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, int256 gammaTerm, int256 netVega, int256 deltaS, int256 deltaVol) - private - pure - returns (uint256) - { - int256 deltaPnl = netDelta * deltaS / int256(WAD); - int256 vegaPnl = netVega * deltaVol / int256(WAD); - // Gamma term is ½γ(Δs)²: positive gamma profits from moves, negative gamma loses. - int256 pnl = deltaPnl + 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; } From 559f3af0e7662106f7b8a2b02f87a27e74b9fdc2 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 12:18:42 +0200 Subject: [PATCH 150/180] fix(contracts): mirror signed perps entry value Align the perps mock position tuple with production semantics while preserving the price-based test setter. Co-authored-by: Cursor --- contracts/contracts/mocks/PerpsDEXMock.sol | 5 +++-- contracts/tests/portfolioMarginEngine.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/mocks/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index a05b1c3..f8fdb0b 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; @@ -34,7 +34,8 @@ contract PerpsDEXMock is ILinearMarket { bool private _riskViewDisabled; function setUserPosition(address user, int256 qty, uint256 entryPrice) external { - _positions[user] = Position(qty, entryPrice); + int256 netEntryValue = qty * int256(entryPrice) / int256(10 ** QUANTITY_DECIMALS); + _positions[user] = Position(qty, netEntryValue); } function setBalance(address user, uint256 bal) external { diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index 964a8a9..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, From 60aed6affd9cc8083be66d07daef68f951d14d70 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 12:28:01 +0200 Subject: [PATCH 151/180] fix(keeper): decode signed perps entry value Use an exact local getter ABI so snapshots and venue PnL match the deployed signed position tuple without waiting on an external ABI release. Co-authored-by: Cursor --- keeper/src/predict/snapshot.ts | 12 +++++++-- keeper/src/venues/perps.ts | 12 ++++----- keeper/src/venues/perpsPositionAbi.ts | 19 ++++++++++++++ keeper/tests/integration/helpers.ts | 5 ++-- keeper/tests/predict/coordinator.test.ts | 5 +++- .../tests/predict/coordinatorAlerts.test.ts | 2 +- keeper/tests/predict/snapshot.test.ts | 15 +++++++++-- keeper/tests/venues/perps.test.ts | 25 +++++++++++++++---- keeper/tests/venues/reduceToTarget.test.ts | 2 +- 9 files changed, 77 insertions(+), 20 deletions(-) create mode 100644 keeper/src/venues/perpsPositionAbi.ts diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index db630ca..0b18096 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -6,6 +6,7 @@ import { PortfolioMarginEngineAbi } from "collateral-margin-abi/PortfolioMarginE import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import { FuturesAbi } from "futures-marketplace-abi/Futures.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 @@ -102,7 +103,7 @@ export async function readAccountSnapshot( }, { address: config.perps.address, - abi: HashPowerPerpsDEXAbi, + abi: PerpsPositionAbi, functionName: "getUserPosition" as const, args: [user] as const, }, @@ -181,7 +182,10 @@ export async function readAccountSnapshot( balance: balance as bigint, perp: { netQty: perpPosition.netQuantity, - entryPrice: perpPosition.aggregatedEntryPrice, + 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, @@ -205,3 +209,7 @@ function restingOrders( sellValue: aggregate.sellValue, }; } + +function abs(value: bigint): bigint { + return value < 0n ? -value : value; +} 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/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 1d845c6..37be777 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; diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 53c0a63..45211b6 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, diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 10529c8..91ec8cc 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, diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index efe87df..0d892c2 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -34,7 +34,7 @@ function makeChain(scripted: { /** 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 +69,7 @@ function makeChain(scripted: { } return { netQuantity: scripted.perpNetQty ?? 0n, - aggregatedEntryPrice: scripted.perpEntry ?? 0n, + netEntryValue: scripted.perpNetEntryValue ?? 0n, }; } case "settlementPrice": { @@ -136,6 +136,7 @@ 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); @@ -176,6 +177,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/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 911cac4..e88e930 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -37,7 +37,7 @@ const EMPTY_RISK_VIEW = { 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, From 9e24a5105744f3589a1cd38db93e57eb16336667 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 12:30:13 +0200 Subject: [PATCH 152/180] fix(market-maker): decode signed perps entry value Read the exact local position tuple and derive average entry prices for both long and short inventory without depending on an unpublished ABI. Co-authored-by: Cursor --- market-maker/src/adapters/perps/instrument.ts | 8 +- .../src/adapters/perps/positionAbi.ts | 19 +++++ .../tests/core/perpsInstrument.test.ts | 81 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 market-maker/src/adapters/perps/positionAbi.ts create mode 100644 market-maker/tests/core/perpsInstrument.test.ts 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/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 }); + }); +}); From e642d9b33bdc77e23d61f2af8636b9e331e87ef0 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 12:33:43 +0200 Subject: [PATCH 153/180] fix(keeper): align integration harness with siblings Run the keeper test node with integration-only size limits disabled and deploy Futures through its current initializer surface. Co-authored-by: Cursor --- keeper/tests/integration/deployStack.ts | 6 ++-- keeper/tests/integration/hardhat.config.ts | 15 +++++++++ keeper/tests/integration/nodeProcess.ts | 39 +++++++++++++--------- 3 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 keeper/tests/integration/hardhat.config.ts 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/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 From ed089e7e2c05263fc4d71bd2d53c7644632e5e28 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 13:26:52 +0200 Subject: [PATCH 154/180] refactor(futures): consume HashPowerFutures artifact Vendor the renamed generated ABI so keeper and market-maker remain independently buildable before the upstream packages publish it. Co-authored-by: Cursor --- .github/workflows/keeper-test.yml | 40 +- keeper/package.json | 1 - keeper/pnpm-lock.yaml | 9 - keeper/scripts/audit-indexer-sync.ts | 4 +- keeper/scripts/debug-delivery-bootstrap.ts | 6 +- keeper/src/abi/HashPowerFutures.ts | 2128 +++++++++++++++++ keeper/src/delivery/coordinator.ts | 20 +- keeper/src/discovery/tracker.ts | 2 +- keeper/src/predict/snapshot.ts | 12 +- keeper/src/venues/futures.ts | 20 +- keeper/tests/integration/artifacts.ts | 7 +- market-maker/package.json | 1 - market-maker/pnpm-lock.yaml | 150 +- market-maker/src/abi/HashPowerFutures.ts | 2128 +++++++++++++++++ market-maker/src/adapters/futures/events.ts | 6 +- .../src/adapters/futures/instrument.ts | 16 +- .../src/adapters/futures/ownOrders.ts | 4 +- market-maker/src/adapters/futures/venue.ts | 19 +- 18 files changed, 4336 insertions(+), 237 deletions(-) create mode 100644 keeper/src/abi/HashPowerFutures.ts create mode 100644 market-maker/src/abi/HashPowerFutures.ts diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 7a74c37..54cc92e 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 diff --git a/keeper/package.json b/keeper/package.json index cd88e6a..bb7d632 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -25,7 +25,6 @@ "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/contracts/abi", "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index def7113..fc7b5d8 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: derivatives-marketplace-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 - futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -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/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303} - version: 0.0.0 - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -464,8 +457,6 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#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..8e40a77 --- /dev/null +++ b/keeper/src/abi/HashPowerFutures.ts @@ -0,0 +1,2128 @@ +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": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getBestAskPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getBestBidPrice", + "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": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getNetPositionDelta", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "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" + } + ], + "name": "getOrderAggregate", + "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": "aggregate_", + "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" + } + ], + "name": "getUserOrders", + "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": "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": "_participant", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "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": "resetParticipantState", + "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/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index bba247b..5082b63 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -8,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"; @@ -75,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), }), @@ -151,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, @@ -258,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[]; @@ -271,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, })), @@ -389,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, @@ -447,7 +447,7 @@ export class DeliveryCoordinator { hash = await withUnstickRetry(this.chain, this.logger, () => this.chain.walletClient.writeContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "settlePositions", args: [users, expirationAts], account: this.chain.account, @@ -511,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/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 0b18096..0486d1b 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -4,7 +4,7 @@ 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"; @@ -121,19 +121,19 @@ export async function readAccountSnapshot( }, { address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getRiskView" as const, args: [user] as const, }, { address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getOrderAggregate" as const, args: [user] as const, }, { address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getActiveExpirationDates" as const, args: [user] as const, }, @@ -148,13 +148,13 @@ export async function readAccountSnapshot( 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, })), diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 18b4f08..695aaff 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" && @@ -82,7 +82,7 @@ export class FuturesVenue implements Venue { async readOpenOrders(user: Address): Promise { const orderIds = (await this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserOrders", args: [user], })) as readonly Hex[]; @@ -92,7 +92,7 @@ export class FuturesVenue implements Venue { 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 +109,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 +125,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, })), @@ -162,7 +162,7 @@ export class FuturesVenue implements Venue { if (targetIds === undefined) { targetIds = (await this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserOrders", args: [user], })) as readonly Hex[]; @@ -194,7 +194,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 +229,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", 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/market-maker/package.json b/market-maker/package.json index 6963482..85c84f8 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", diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 6457c1c..de471e3 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==} @@ -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.*} @@ -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: {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/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts new file mode 100644 index 0000000..8e40a77 --- /dev/null +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -0,0 +1,2128 @@ +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": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getBestAskPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getBestBidPrice", + "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": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getNetPositionDelta", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "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" + } + ], + "name": "getOrderAggregate", + "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": "aggregate_", + "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" + } + ], + "name": "getUserOrders", + "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": "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": "_participant", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "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": "resetParticipantState", + "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 1ae7421..20ddcfe 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -5,7 +5,7 @@ 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"; @@ -96,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, })); diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index ec4c86a..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]; @@ -283,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", }, ], @@ -344,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); @@ -404,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 => { From fbc4515fbbe571f1de70cc26628d65213178cc48 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 13:46:29 +0200 Subject: [PATCH 155/180] fix(futures): sync consumer ABI Co-authored-by: Cursor --- keeper/src/abi/HashPowerFutures.ts | 13 ------------- market-maker/src/abi/HashPowerFutures.ts | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/keeper/src/abi/HashPowerFutures.ts b/keeper/src/abi/HashPowerFutures.ts index 8e40a77..88f9399 100644 --- a/keeper/src/abi/HashPowerFutures.ts +++ b/keeper/src/abi/HashPowerFutures.ts @@ -1767,19 +1767,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address[]", - "name": "_participants", - "type": "address[]" - } - ], - "name": "resetParticipantState", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { diff --git a/market-maker/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts index 8e40a77..88f9399 100644 --- a/market-maker/src/abi/HashPowerFutures.ts +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -1767,19 +1767,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address[]", - "name": "_participants", - "type": "address[]" - } - ], - "name": "resetParticipantState", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { From ecb877be5e41ca5815f98f629dd6524e93fd4658 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 14:05:00 +0200 Subject: [PATCH 156/180] fix(keeper): read futures orders via AtExpiration multicalls Follow the thinner futures ABI: discover ids and aggregates over getExpirationDates instead of removed cross-expiry getters. Co-authored-by: Cursor --- keeper/src/abi/HashPowerFutures.ts | 117 ------------------ keeper/src/predict/snapshot.ts | 62 +++++++--- keeper/src/venues/futures.ts | 45 +++++-- keeper/src/venues/types.ts | 3 +- keeper/tests/integration/helpers.ts | 24 +++- keeper/tests/predict/coordinator.test.ts | 4 + .../tests/predict/coordinatorAlerts.test.ts | 4 + keeper/tests/predict/snapshot.test.ts | 32 ++++- keeper/tests/venues/futures.test.ts | 36 ++++-- keeper/tests/venues/reduceToTarget.test.ts | 26 ++-- market-maker/src/abi/HashPowerFutures.ts | 117 ------------------ 11 files changed, 184 insertions(+), 286 deletions(-) diff --git a/keeper/src/abi/HashPowerFutures.ts b/keeper/src/abi/HashPowerFutures.ts index 88f9399..d2b2255 100644 --- a/keeper/src/abi/HashPowerFutures.ts +++ b/keeper/src/abi/HashPowerFutures.ts @@ -1003,44 +1003,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_expirationAt", - "type": "uint256" - } - ], - "name": "getBestAskPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_expirationAt", - "type": "uint256" - } - ], - "name": "getBestBidPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "getExpirationDates", @@ -1067,25 +1029,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getNetPositionDelta", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -1127,47 +1070,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getOrderAggregate", - "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": "aggregate_", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -1347,25 +1249,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getUserOrders", - "outputs": [ - { - "internalType": "bytes32[]", - "name": "orderIds", - "type": "bytes32[]" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 0486d1b..d1137f2 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -64,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` / `getOrderAggregate`, - * 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 `getOrderAggregate` 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. */ @@ -91,8 +92,8 @@ export async function readAccountSnapshot( perpRisk, perpOrderAggregate, futuresRisk, - futuresOrderAggregate, activeExpirationAts, + tradableExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ { @@ -128,22 +129,36 @@ export async function readAccountSnapshot( { address: config.futures.address, abi: HashPowerFuturesAbi, - functionName: "getOrderAggregate" as const, + functionName: "getActiveExpirationDates" as const, args: [user] as const, }, { address: config.futures.address, abi: HashPowerFuturesAbi, - functionName: "getActiveExpirationDates" as const, - args: [user] as const, + 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) => ({ @@ -158,9 +173,16 @@ export async function readAccountSnapshot( 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; @@ -174,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; diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 695aaff..b5b7aba 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -80,13 +80,7 @@ export class FuturesVenue implements Venue { } async readOpenOrders(user: Address): Promise { - const orderIds = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: HashPowerFuturesAbi, - 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({ @@ -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: HashPowerFuturesAbi, - functionName: "getUserOrders", - args: [user], - })) as readonly Hex[]; + targetIds = await this.readActiveOrderIds(user); } if (targetIds.length === 0) { return { skipped: "notLiquidatable" }; @@ -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/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/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 37be777..859ed91 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -139,12 +139,28 @@ 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 stack.publicClient.multicall({ + contracts: expirationAts.map((expirationAt) => ({ + address: stack.addresses.futures, + abi: stack.abis.futures, + 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; } /** Resolves to true once `user` is flat on perps. */ diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 45211b6..d0c29cb 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -134,8 +134,12 @@ function buildHarness({ }; 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 91ec8cc..7dd7bd2 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -106,8 +106,12 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi }; 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 0d892c2..4aa760d 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -30,6 +30,8 @@ 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; @@ -91,8 +93,8 @@ function makeChain(scripted: { }; } case "getOrderAggregate": { - const orders = - (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; + // Perps-only cross-user aggregate. + const orders = scripted.perpOrders ?? NO_ORDERS; return { buyQty: 0n, sellQty: 0n, @@ -100,8 +102,34 @@ function makeChain(scripted: { 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": 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/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index e88e930..ba8d14e 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -30,9 +30,9 @@ const EMPTY_RISK_VIEW = { } as const; /** - * The bulk read `readAccountSnapshot` issues, in order: balance, the perp position, - * then each venue's `getRiskView` / `getOrderAggregate` 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 [ @@ -41,7 +41,7 @@ function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { 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/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts index 88f9399..d2b2255 100644 --- a/market-maker/src/abi/HashPowerFutures.ts +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -1003,44 +1003,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_expirationAt", - "type": "uint256" - } - ], - "name": "getBestAskPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_expirationAt", - "type": "uint256" - } - ], - "name": "getBestBidPrice", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "getExpirationDates", @@ -1067,25 +1029,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "getNetPositionDelta", - "outputs": [ - { - "internalType": "int256", - "name": "", - "type": "int256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -1127,47 +1070,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getOrderAggregate", - "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": "aggregate_", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -1347,25 +1249,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_user", - "type": "address" - } - ], - "name": "getUserOrders", - "outputs": [ - { - "internalType": "bytes32[]", - "name": "orderIds", - "type": "bytes32[]" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { From b4e8d2973d607da7d0a26309c3e79a91984f602a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 14:23:56 +0200 Subject: [PATCH 157/180] =?UTF-8?q?feat(pme):=20add=20isLiquidatable=20?= =?UTF-8?q?=E2=80=94=20the=20canonical=20cross-venue=20health=20predicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liquidatability is a property of the portfolio (balance < portfolio MM), not of any single venue, so the predicate moves here. The venues' own isLiquidatable views answered it inconsistently (futures gated on venue-local state, perps on portfolio-wide order delta) and are being removed; whether a venue holds anything actionable stays answerable via its hasRestingOrderDelta and position views. Co-authored-by: Cursor --- contracts/contracts/PortfolioMarginEngine.sol | 11 ++++++++++- .../contracts/interfaces/IPortfolioMarginEngine.sol | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 8776533..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 ───────────────────────────────────────────────────────────── @@ -281,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; diff --git a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol index 55a822f..f59b6d5 100644 --- a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol +++ b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol @@ -22,6 +22,15 @@ interface IPortfolioMarginEngine { /// @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 From d6eea43b8d477d7ebf4e7bde6fba1c0f4047ca9a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 10 Aug 2026 14:41:23 +0200 Subject: [PATCH 158/180] refactor: drop venue isLiquidatable from mocks, ABIs and consumers The venues removed their isLiquidatable views; the PME predicate added in the previous commit is the single source. PerpsDEXMock loses the dead balance/maintenance-margin model, MarginEngineMock answers the new interface member, the keeper/market-maker HashPowerFutures ABI copies are synced from the regenerated futures ABI, and the pending MM process test reads the predicate from the PME. Co-authored-by: Cursor --- contracts/abi/ContractErrors.json | 5 ++++ contracts/abi/ContractErrors.ts | 5 ++++ contracts/abi/IPortfolioMarginEngine.json | 19 +++++++++++++++ contracts/abi/IPortfolioMarginEngine.ts | 19 +++++++++++++++ contracts/abi/PortfolioMarginEngine.json | 24 +++++++++++++++++++ contracts/abi/PortfolioMarginEngine.ts | 24 +++++++++++++++++++ .../contracts/mocks/MarginEngineMock.sol | 5 ++++ contracts/contracts/mocks/PerpsDEXMock.sol | 16 ------------- keeper/src/abi/HashPowerFutures.ts | 19 --------------- market-maker/src/abi/HashPowerFutures.ts | 19 --------------- .../market-maker.process.test.ts | 4 ++-- 11 files changed, 103 insertions(+), 56 deletions(-) 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 4be2dfc..ecb685b 100644 --- a/contracts/abi/IPortfolioMarginEngine.json +++ b/contracts/abi/IPortfolioMarginEngine.json @@ -93,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 a9a1c57..138323c 100644 --- a/contracts/abi/IPortfolioMarginEngine.ts +++ b/contracts/abi/IPortfolioMarginEngine.ts @@ -93,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 a5dc6b5..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": [ { @@ -476,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 1461c08..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": [ { @@ -476,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/mocks/MarginEngineMock.sol b/contracts/contracts/mocks/MarginEngineMock.sol index ef888d2..57d03ea 100644 --- a/contracts/contracts/mocks/MarginEngineMock.sol +++ b/contracts/contracts/mocks/MarginEngineMock.sol @@ -49,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/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index f8fdb0b..c446c32 100644 --- a/contracts/contracts/mocks/PerpsDEXMock.sol +++ b/contracts/contracts/mocks/PerpsDEXMock.sol @@ -23,9 +23,7 @@ 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; @@ -38,19 +36,10 @@ contract PerpsDEXMock is ILinearMarket { _positions[user] = Position(qty, netEntryValue); } - function setBalance(address user, uint256 bal) external { - _balances[user] = bal; - } - 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]; } @@ -105,9 +94,4 @@ contract PerpsDEXMock is ILinearMarket { function hasRestingOrderDelta(address user) external view returns (bool) { return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; } - - function isLiquidatable(address user) external view returns (bool) { - if (_positions[user].netQuantity == 0) return false; - return _balances[user] < _maintenanceMargin[user]; - } } diff --git a/keeper/src/abi/HashPowerFutures.ts b/keeper/src/abi/HashPowerFutures.ts index d2b2255..f4d4ae3 100644 --- a/keeper/src/abi/HashPowerFutures.ts +++ b/keeper/src/abi/HashPowerFutures.ts @@ -1369,25 +1369,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "isLiquidatable", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { diff --git a/market-maker/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts index d2b2255..f4d4ae3 100644 --- a/market-maker/src/abi/HashPowerFutures.ts +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -1369,25 +1369,6 @@ export const HashPowerFuturesAbi = [ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_participant", - "type": "address" - } - ], - "name": "isLiquidatable", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { 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; From b8a7647ed8f8061aa9fcd6305d26512935cad88e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 12:22:17 +0200 Subject: [PATCH 159/180] fix(keeper): pin pnpm 10 to keep git subpath deps pnpm 11.1.1 records git subpath dependencies as integrity-only, dropping the `path:` field. A cold store then unpacks the whole repository into node_modules, so collateral-margin-abi/CollateralVault.ts and @hashpower/portfolio-margin do not resolve and TypeCheck fails with TS2307 on every CI run. --- keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/keeper/package.json b/keeper/package.json index bb7d632..4e8286f 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -34,5 +34,5 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" }, - "packageManager": "pnpm@11.1.1" + "packageManager": "pnpm@10.28.1" } diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index fc7b5d8..3aaef0c 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -67,28 +67,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.13': resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.13': resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.13': resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.13': resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} @@ -103,7 +99,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: {path: /portfolio-margin, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} version: 0.1.0 engines: {node: '>=22'} @@ -201,7 +197,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: {path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} version: 0.0.0 colorette@2.0.20: @@ -211,7 +207,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535} + resolution: {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: From 5f74df86318bd67682b7e79a5142103a517f9023 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 12:22:26 +0200 Subject: [PATCH 160/180] fix(ci): run market-maker tests on node 24 Node 22 refuses to strip types from .ts files under node_modules, so any test reaching perps-contracts/abi/HashPowerPerpsDEX.ts crashes on import. Match the keeper workflow, which already runs node 24. --- .github/workflows/market-maker-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml index 0ac2149..5e99f00 100644 --- a/.github/workflows/market-maker-tests.yml +++ b/.github/workflows/market-maker-tests.yml @@ -50,7 +50,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: "pnpm" cache-dependency-path: market-maker/pnpm-lock.yaml From d4e7659c9ae4cac04e703ca7b786c1837ec87d50 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 12:41:49 +0200 Subject: [PATCH 161/180] fix(ci): pin siblings to the converged contract branches The keeper integration harness deploys HashPowerFutures and reads a signed netEntryValue, neither of which exists at the previously pinned commits, so every fixture failed on a missing Hardhat artifact. Track futures #258 and perps #97 until both land on dev. --- .github/workflows/keeper-test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 54cc92e..5697804 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -88,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 From a8ac309ea8f7114844d2175f970dcbbd00d11368 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 12:41:49 +0200 Subject: [PATCH 162/180] fix(keeper): read futures order ids without multicall3 The test public client has no multicall3 configured, matching every other reader in this file, so readFuturesOrderIds must fan out readContract calls instead of batching them. --- keeper/tests/integration/helpers.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 859ed91..20c1563 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -146,18 +146,20 @@ export async function readFuturesOrderIds( })) as readonly bigint[]; if (expirationAts.length === 0) return []; - const perExpiry = await stack.publicClient.multicall({ - contracts: expirationAts.map((expirationAt) => ({ - address: stack.addresses.futures, - abi: stack.abis.futures, - functionName: "getUserOrdersAtExpiration" as const, - args: [user, expirationAt] as const, - })), - allowFailure: false, - }); + 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 as readonly (readonly Hex[])[]) { + for (const ids of perExpiry) { for (const id of ids) orderIds.push(id); } return orderIds; From a1bf400e3dfeac83f793072d06a4bfd072d354a7 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 13:36:57 +0200 Subject: [PATCH 163/180] fix(ci): run market-maker tests through the package script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoking node directly skipped `--import=amaro/strip`, so the first test to load a contract ABI shipped as .ts inside node_modules crashed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Match the keeper workflow's action versions too, and drop the pnpm cache from both so CI always installs against a cold store — a warm store is what hid the git subpath breakage. --- .github/workflows/keeper-test.yml | 2 -- .github/workflows/market-maker-tests.yml | 16 +++++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 5697804..f4fb2e5 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -44,8 +44,6 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24" - cache: "pnpm" - cache-dependency-path: keeper/pnpm-lock.yaml - name: Install keeper dependencies working-directory: ./keeper diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml index 5e99f00..6d8e3c6 100644 --- a/.github/workflows/market-maker-tests.yml +++ b/.github/workflows/market-maker-tests.yml @@ -40,19 +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: "24" - cache: "pnpm" - cache-dependency-path: market-maker/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -63,8 +61,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 From a06131f4b7a00d90c0cfc728e4b9ee4629e895d8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 14:13:42 +0200 Subject: [PATCH 164/180] chore: adopt pnpm 11.22 across every package pnpm 11.1.1 dropped the `path:` field from git subpath resolutions, which made installs depend on store state (pnpm/pnpm#12304, fixed in 11.7.0). Move every package to a release carrying that fix instead of holding keeper back on pnpm 10, and restore the CI store cache now that a warm store can no longer mask a lossy lockfile. pnpm 11 migrates the contracts build allowance out of package.json into pnpm-workspace.yaml. --- .github/workflows/keeper-test.yml | 2 ++ .github/workflows/market-maker-tests.yml | 2 ++ contracts/package.json | 7 +------ contracts/pnpm-lock.yaml | 2 +- contracts/pnpm-workspace.yaml | 3 +++ indexer/package.json | 2 +- keeper/package.json | 2 +- keeper/pnpm-lock.yaml | 10 +++++++--- market-maker/package.json | 2 +- market-maker/pnpm-lock.yaml | 12 ++++++------ points-indexer/package.json | 2 +- portfolio-margin/package.json | 2 +- 12 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 contracts/pnpm-workspace.yaml diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index f4fb2e5..5697804 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -44,6 +44,8 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24" + cache: "pnpm" + cache-dependency-path: keeper/pnpm-lock.yaml - name: Install keeper dependencies working-directory: ./keeper diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml index 6d8e3c6..d9d1611 100644 --- a/.github/workflows/market-maker-tests.yml +++ b/.github/workflows/market-maker-tests.yml @@ -51,6 +51,8 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24" + cache: "pnpm" + cache-dependency-path: market-maker/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile 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/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 4e8286f..16d8813 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -34,5 +34,5 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" }, - "packageManager": "pnpm@10.28.1" + "packageManager": "pnpm@11.22.0" } diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 3aaef0c..7a38658 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -67,24 +67,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.13': resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.13': resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.13': resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.13': resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} @@ -99,7 +103,7 @@ packages: os: [win32] '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin': - resolution: {path: /portfolio-margin, 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'} @@ -197,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: {path: /contracts/abi, 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: @@ -207,7 +211,7 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: - resolution: {path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535} + 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: diff --git a/market-maker/package.json b/market-maker/package.json index 85c84f8..429e312 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -52,7 +52,7 @@ "pino-pretty": "^13.1.3", "typescript": "^5.8.0" }, - "packageManager": "pnpm@10.28.1", + "packageManager": "pnpm@11.22.0", "pnpm": { "onlyBuiltDependencies": [ "keccak@3.0.4", diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index de471e3..127eddb 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -1212,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: @@ -1304,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} @@ -1346,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: @@ -1595,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: @@ -1876,7 +1876,7 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012: - resolution: {tarball: 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: diff --git a/points-indexer/package.json b/points-indexer/package.json index 34e8f44..c13b147 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -51,5 +51,5 @@ "hardhat-matchstick-ts" ] }, - "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" } 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" } From c4c615393fb57e03a1340b4bd74d58e09e781154 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 14:36:58 +0200 Subject: [PATCH 165/180] fix(ci): declare build allowances for pnpm 11 pnpm 11 stopped reading `pnpm.onlyBuiltDependencies` from package.json and turned ignored build scripts into a hard install error, so market-maker and points-indexer both failed to install. Move the allowances to allowBuilds and name the packages that must stay unbuilt. Git dependencies are keyed by exact resolution, so matchstick-ts is listed at both pinned commits. --- market-maker/package.json | 8 +------- market-maker/pnpm-workspace.yaml | 4 ++++ points-indexer/package.json | 6 ------ points-indexer/pnpm-workspace.yaml | 9 +++++++++ 4 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 market-maker/pnpm-workspace.yaml create mode 100644 points-indexer/pnpm-workspace.yaml diff --git a/market-maker/package.json b/market-maker/package.json index 429e312..efd1030 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -52,11 +52,5 @@ "pino-pretty": "^13.1.3", "typescript": "^5.8.0" }, - "packageManager": "pnpm@11.22.0", - "pnpm": { - "onlyBuiltDependencies": [ - "keccak@3.0.4", - "secp256k1@4.0.4" - ] - } + "packageManager": "pnpm@11.22.0" } 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/points-indexer/package.json b/points-indexer/package.json index c13b147..b7bb961 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -45,11 +45,5 @@ "typescript": "^5.9.3", "viem": "2.52.2" }, - "pnpm": { - "onlyBuiltDependencies": [ - "matchstick-ts", - "hardhat-matchstick-ts" - ] - }, "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" } diff --git a/points-indexer/pnpm-workspace.yaml b/points-indexer/pnpm-workspace.yaml new file mode 100644 index 0000000..e39e68a --- /dev/null +++ b/points-indexer/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +# pnpm 11 keys allowBuilds by exact resolution, so a git dependency needs one +# entry per pinned commit. Both the current pin and the v0.4.2 tag are listed so +# bumping the spec does not break installs mid-flight. +allowBuilds: + esbuild: false + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/hardhat-matchstick-ts: true + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/matchstick-ts: true + 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 From 8741e7009c3285918de1b47aea4604280c026f10 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 14:50:54 +0200 Subject: [PATCH 166/180] chore(points-indexer): track matchstick-ts by tag Follow the v0.4.2 tag instead of a raw commit and repoint the allowBuilds entries at the resolution it pins. --- points-indexer/package.json | 4 ++-- points-indexer/pnpm-lock.yaml | 24 ++++++++++++------------ points-indexer/pnpm-workspace.yaml | 7 ++----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/points-indexer/package.json b/points-indexer/package.json index b7bb961..8badcc6 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -39,9 +39,9 @@ "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" }, 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 index e39e68a..9c2036b 100644 --- a/points-indexer/pnpm-workspace.yaml +++ b/points-indexer/pnpm-workspace.yaml @@ -1,9 +1,6 @@ -# pnpm 11 keys allowBuilds by exact resolution, so a git dependency needs one -# entry per pinned commit. Both the current pin and the v0.4.2 tag are listed so -# bumping the spec does not break installs mid-flight. +# 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/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/hardhat-matchstick-ts: true - matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/matchstick-ts: true 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 From f947b29dadd9e4be2c54b53a2115733cb051f485 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 15:46:05 +0200 Subject: [PATCH 167/180] chore: adopt pnpm 11.22 at the repo root --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 21bff2a..269ade4 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,5 @@ "keywords": [], "author": "", "license": "ISC", - "packageManager": "pnpm@10.28.1" + "packageManager": "pnpm@11.22.0" } \ No newline at end of file From f31525af8198d07d845bc3b4629be5d850e3134e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Mon, 17 Aug 2026 18:01:34 +0200 Subject: [PATCH 168/180] fix(mm): ship pnpm-workspace.yaml into the Docker deps stage pnpm 11 stopped reading build allowances from package.json and turned ignored build scripts into a hard install error. The allowances were moved to market-maker/pnpm-workspace.yaml, but the deps stage only copies package.json and pnpm-lock.yaml, so `pnpm install --frozen-lockfile --prod` still aborted with ERR_PNPM_IGNORED_BUILDS and no image was published. That left the dev market-maker pinned to a 10-day-old image whose futures own-order bootstrap still calls the removed `getUserOrders`, quarantining every futures expiry. --- market-maker/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/market-maker/Dockerfile b/market-maker/Dockerfile index 1c817c0..86d3880 100644 --- a/market-maker/Dockerfile +++ b/market-maker/Dockerfile @@ -5,7 +5,9 @@ WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml ./ +# pnpm-workspace.yaml carries the allowBuilds allowances; pnpm 11 treats ignored +# build scripts as a hard install error, so it must be present before install. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── From 0ebb77655bec948d43f845d690b597300865626a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 1 Sep 2026 15:03:44 +0200 Subject: [PATCH 169/180] fix(keeper): forward discovery backfill block Pass the optional startup anchor into ECS so keeper restarts can rediscover existing participants without treating an unset value as block zero. --- .github/workflows/deploy-keeper.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index ec1b2b8..19ea242 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -22,6 +22,7 @@ name: Deploy Collateral Margin Keeper # KEEPER_DESIRED_COUNT default "1" (set "0" to halt) # LOG_LEVEL default "info" # DISCOVERY_MODE events | webhook | both (default events) +# BACKFILL_FROM_BLOCK optional startup discovery backfill anchor # DELIVERY_KEEPER_ENABLED default "false" # HEALTH_PORT default "3000" (must match TF cnt_port) # PRICE_MOVE_TRIGGER_BPS default "1" @@ -241,6 +242,7 @@ jobs: KEEPER_DESIRED_COUNT: ${{ vars.KEEPER_DESIRED_COUNT }} LOG_LEVEL: ${{ vars.LOG_LEVEL }} DISCOVERY_MODE: ${{ vars.DISCOVERY_MODE }} + BACKFILL_FROM_BLOCK: ${{ vars.BACKFILL_FROM_BLOCK }} DELIVERY_KEEPER_ENABLED: ${{ vars.DELIVERY_KEEPER_ENABLED }} HEALTH_PORT: ${{ vars.HEALTH_PORT }} PRICE_MOVE_TRIGGER_BPS: ${{ vars.PRICE_MOVE_TRIGGER_BPS }} @@ -288,6 +290,7 @@ jobs: --arg KEEPER_MIN_PROFIT_MARGIN "${KEEPER_MIN_PROFIT_MARGIN:-0}" \ --arg LOG_LEVEL "${LOG_LEVEL:-info}" \ --arg DISCOVERY_MODE "${DISCOVERY_MODE:-events}" \ + --arg BACKFILL_FROM_BLOCK "${BACKFILL_FROM_BLOCK:-}" \ --arg DELIVERY_KEEPER_ENABLED "${DELIVERY_KEEPER_ENABLED:-false}" \ --arg HEALTH_PORT "${HEALTH_PORT:-3000}" \ --arg PRICE_MOVE_TRIGGER_BPS "${PRICE_MOVE_TRIGGER_BPS:-1}" \ @@ -321,7 +324,11 @@ jobs: {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, {name:"LIQUIDATOR_PRIVATE_KEY", value:$LIQUIDATOR_PRIVATE_KEY}, {name:"WEBHOOK_SECRET", value:$WEBHOOK_SECRET} - ]' > env-block.json + ] + ( + if $BACKFILL_FROM_BLOCK == "" then [] + else [{name:"BACKFILL_FROM_BLOCK", value:$BACKFILL_FROM_BLOCK}] + end + )' > env-block.json jq --slurpfile env env-block.json ' .containerDefinitions[0].environment = $env[0] | From 51db67426507e8ab1d444e14b399c5a457de112a Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 1 Sep 2026 15:49:56 +0200 Subject: [PATCH 170/180] feat(keeper): index futures participants by expiry Rebuild bounded per-expiry caches on startup so liquidation and settlement discover pre-existing Futures users without deployment-wide history scans. --- keeper/README.md | 8 +- keeper/src/delivery/coordinator.ts | 47 +- keeper/src/discovery/combined.ts | 49 ++ keeper/src/discovery/futuresExpiryIndex.ts | 547 ++++++++++++++++++ keeper/src/discovery/tracker.ts | 83 +-- keeper/src/discovery/types.ts | 12 + keeper/src/index.ts | 101 +--- keeper/src/predict/coordinator.ts | 6 +- keeper/src/runtime/healthcheck.ts | 25 +- keeper/src/runtime/scheduler.ts | 6 +- keeper/tests/delivery/coordinator.test.ts | 33 ++ keeper/tests/discovery/combined.test.ts | 73 +++ .../discovery/futuresExpiryIndex.test.ts | 226 ++++++++ keeper/tests/discovery/tracker.test.ts | 16 +- keeper/tests/integration/buildKeeper.ts | 28 +- keeper/tests/integration/helpers.ts | 4 +- .../integration/keeper.integration.test.ts | 14 +- keeper/tests/runtime/healthcheck.test.ts | 43 ++ 18 files changed, 1131 insertions(+), 190 deletions(-) create mode 100644 keeper/src/discovery/combined.ts create mode 100644 keeper/src/discovery/futuresExpiryIndex.ts create mode 100644 keeper/src/discovery/types.ts create mode 100644 keeper/tests/discovery/combined.test.ts create mode 100644 keeper/tests/discovery/futuresExpiryIndex.test.ts diff --git a/keeper/README.md b/keeper/README.md index a1c3b14..48b9795 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -91,7 +91,9 @@ src/ predictiveIndex.ts # Sorted threshold index (down ASC, up ASC) with O(log) crossings coordinator.ts # PriceFeed + tracker → index → CoordinatorQueue + executor.kick discovery/ - tracker.ts # Event-driven participant set + one-shot startup backfill + tracker.ts # Vault/Perps participant set + one-shot startup backfill + futuresExpiryIndex.ts # Bounded participant/position cache per Futures expiry + combined.ts # Deduplicated union consumed by scheduler/predictor webhook.ts # Optional Goldsky webhook ingester (Bearer-token auth) venues/ types.ts # Venue interface (multi-market aware: perps, futures, options) @@ -153,7 +155,7 @@ See `src/config.ts` for the authoritative shape. The minimum-viable set: | `BTC_USDC_FEED_ADDRESS` | yes | Chainlink BTC/USDC AggregatorProxy (event source) | | `PRICE_MOVE_TRIGGER_BPS` | no | Skip ticks below this fractional move (default `1`) | | `DISCOVERY_MODE` | no | `events` (default) \| `webhook` \| `both` | -| `BACKFILL_FROM_BLOCK` | no | Block to start the one-shot startup backfill from (vault/perps/futures discovery events). Unset = forward-only — only safe with webhook discovery or a previously-warm tracker. | +| `BACKFILL_FROM_BLOCK` | no | Block to start the one-shot Vault/Perps startup backfill. Futures independently replays the bounded lifetime of active and previous expiries. | | `BACKFILL_CHUNK_SIZE` | no | Per-`getLogs` page size for backfill. Default `10000` (most public RPC limit). | | `DRY_RUN` | no | `true` to skip on-chain broadcasts | | `ALERT_WEBHOOK_URL` | no | Slack/Discord/PagerDuty endpoint | @@ -221,6 +223,8 @@ Suites cover: - `coordinator/planner` — orders-leg, position ranking, `OrdersStillOpen`-replay, bad-debt - `alert/notifier` — dedupe window, severity promotion, ordering, retry-on-failure - `discovery/tracker` — checksum dedupe, `onAdded` / `onChanged` listeners, startup backfill +- `discovery/futuresExpiryIndex` — scoped replay, per-expiry partitioning, rollover retention +- `discovery/combined` — deduplicated Vault/Perps + Futures participant union - `discovery/webhook` — payload extraction across `data` / `records` / array shapes - `runtime/scheduler` — alert ladder thresholds, queue upsert + executor kick wiring - `oracle/priceFeed` — rebase to token decimals (oracle already quotes 1 PH/s·day), dispatch, no-op on unchanged answer diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index 5082b63..f24f1cd 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -11,6 +11,7 @@ import type pino from "pino"; import { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; +import type { FuturesExpiryIndex } from "../discovery/futuresExpiryIndex.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; import { formatGasCost } from "../tx/gasCost.ts"; @@ -44,23 +45,27 @@ export class DeliveryCoordinator { private txChain: Promise = Promise.resolve(); private unwatchers: Array<() => void> = []; private sweepTimer: NodeJS.Timeout | undefined; + private disposeExpiryIndex: (() => void) | undefined; private running = false; private readonly chain: Chain; private readonly config: Config; private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; + private readonly expiryIndex: FuturesExpiryIndex | undefined; constructor( chain: Chain, config: Config, logger: pino.Logger, ethUsdFeed?: EthUsdFeed, + expiryIndex?: FuturesExpiryIndex, ) { this.chain = chain; this.config = config; this.logger = logger.child({ component: "deliveryCoordinator" }); this.ethUsdFeed = ethUsdFeed; + this.expiryIndex = expiryIndex; } async start(): Promise { @@ -72,20 +77,32 @@ export class DeliveryCoordinator { "delivery coordinator starting (permissionless settlePosition)", ); - this.unwatchers.push( - this.chain.publicClient.watchContractEvent({ - address: this.config.futures.address, - abi: HashPowerFuturesAbi, - eventName: "OrderMatched", - onLogs: (logs) => this.onOrderMatched(logs), - }), - this.chain.publicClient.watchContractEvent({ - address: this.config.futures.address, - abi: HashPowerFuturesAbi, - eventName: "PositionSettled", - onLogs: (logs) => this.onPositionSettled(logs), - }), - ); + if (this.expiryIndex !== undefined) { + for (const pos of this.expiryIndex.positionEntries()) { + this.upsertTracked(pos.user, pos.expirationAt); + } + this.disposeExpiryIndex = this.expiryIndex.onPositionChanged( + (user, expirationAt, active) => { + if (active) this.upsertTracked(user, expirationAt); + else this.dropTracked(user, expirationAt); + }, + ); + } else { + this.unwatchers.push( + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName: "OrderMatched", + onLogs: (logs) => this.onOrderMatched(logs), + }), + this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName: "PositionSettled", + onLogs: (logs) => this.onPositionSettled(logs), + }), + ); + } this.sweepTimer = setInterval(() => { void this.sweep(); @@ -102,6 +119,8 @@ export class DeliveryCoordinator { } for (const t of this.timers.values()) clearTimeout(t); this.timers.clear(); + this.disposeExpiryIndex?.(); + this.disposeExpiryIndex = undefined; for (const u of this.unwatchers) { try { diff --git a/keeper/src/discovery/combined.ts b/keeper/src/discovery/combined.ts new file mode 100644 index 0000000..2676580 --- /dev/null +++ b/keeper/src/discovery/combined.ts @@ -0,0 +1,49 @@ +import { getAddress, type Address } from "viem"; +import type { + ParticipantListener, + ParticipantSource, +} from "./types.ts"; + +/** Deduplicated live union of independent participant discovery sources. */ +export class CombinedParticipantSource implements ParticipantSource { + private readonly sources: readonly ParticipantSource[]; + + constructor(sources: readonly ParticipantSource[]) { + this.sources = sources; + } + + list(): Address[] { + const users = new Map(); + for (const source of this.sources) { + for (const user of source.list()) { + const checksummed = getAddress(user); + users.set(checksummed.toLowerCase(), checksummed); + } + } + return Array.from(users.values()); + } + + size(): number { + return this.list().length; + } + + has(user: Address): boolean { + return this.sources.some((source) => source.has(user)); + } + + onAdded(listener: ParticipantListener): () => void { + return combineDisposers(this.sources.map((source) => source.onAdded(listener))); + } + + onChanged(listener: ParticipantListener): () => void { + return combineDisposers( + this.sources.map((source) => source.onChanged(listener)), + ); + } +} + +function combineDisposers(disposers: Array<() => void>): () => void { + return () => { + for (const dispose of disposers) dispose(); + }; +} diff --git a/keeper/src/discovery/futuresExpiryIndex.ts b/keeper/src/discovery/futuresExpiryIndex.ts new file mode 100644 index 0000000..c961a1d --- /dev/null +++ b/keeper/src/discovery/futuresExpiryIndex.ts @@ -0,0 +1,547 @@ +import { + getAddress, + type Address, + type Log, +} from "viem"; +import type pino from "pino"; +import { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; +import type { Chain } from "../chain.ts"; +import type { Config } from "../config.ts"; +import type { + ParticipantListener, + ParticipantSource, +} from "./types.ts"; + +export interface ExpiryPosition { + user: Address; + expirationAt: bigint; +} + +export type PositionListener = ( + user: Address, + expirationAt: bigint, + active: boolean, +) => void; + +interface ExpiryBucket { + expirationAt: bigint; + participants: Set
; + positions: Set
; +} + +/** + * Bounded Futures discovery index. Each order book gets an independent cache, + * rebuilt from only that market's lifetime rather than contract deployment. + */ +export class FuturesExpiryIndex implements ParticipantSource { + private readonly buckets = new Map(); + private readonly addedListeners = new Set(); + private readonly changedListeners = new Set(); + private readonly positionListeners = new Set(); + private unwatchers: Array<() => void> = []; + private refreshTimer: NodeJS.Timeout | undefined; + private previousExpiry: bigint | undefined; + private replayFromBlock: bigint | undefined; + private replayHeadBlock: bigint | undefined; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + ) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ component: "futuresExpiryIndex" }); + } + + async start(): Promise { + this.unwatchers.push( + this.watch("OrderCreated", (logs) => this.onOrderCreated(logs)), + this.watch("OrderMatched", (logs) => this.onOrderMatched(logs)), + this.watch("PositionLiquidated", (logs) => this.onPositionLiquidated(logs)), + this.watch("PositionSettled", (logs) => this.onPositionSettled(logs)), + ); + + await this.bootstrap(); + const seeds = [ + this.chain.account.address, + ...this.config.delivery.bootstrapUsers, + ]; + await this.seedUsers(seeds); + + this.refreshTimer = setInterval(() => { + void this.refreshWindow().catch((err) => { + this.logger.error({ err }, "futures expiry window refresh failed"); + }); + }, this.config.delivery.sweepIntervalMs); + if (typeof this.refreshTimer.unref === "function") this.refreshTimer.unref(); + } + + stop(): void { + if (this.refreshTimer !== undefined) clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + for (const unwatch of this.unwatchers) { + try { + unwatch(); + } catch (err) { + this.logger.warn({ err }, "futures expiry unwatcher threw"); + } + } + this.unwatchers = []; + } + + /** Refresh the rolling expiry window immediately. Public for operations/tests. */ + async refresh(): Promise { + await this.refreshWindow(); + } + + list(): Address[] { + const users = new Map(); + for (const bucket of this.buckets.values()) { + for (const user of bucket.participants) { + users.set(user.toLowerCase(), user); + } + } + return Array.from(users.values()); + } + + size(): number { + return this.list().length; + } + + has(user: Address): boolean { + const key = getAddress(user); + for (const bucket of this.buckets.values()) { + if (bucket.participants.has(key)) return true; + } + return false; + } + + onAdded(listener: ParticipantListener): () => void { + this.addedListeners.add(listener); + return () => this.addedListeners.delete(listener); + } + + onChanged(listener: ParticipantListener): () => void { + this.changedListeners.add(listener); + return () => this.changedListeners.delete(listener); + } + + onPositionChanged(listener: PositionListener): () => void { + this.positionListeners.add(listener); + return () => this.positionListeners.delete(listener); + } + + positionEntries(): ExpiryPosition[] { + const out: ExpiryPosition[] = []; + for (const bucket of this.buckets.values()) { + for (const user of bucket.positions) { + out.push({ user, expirationAt: bucket.expirationAt }); + } + } + return out; + } + + stats(): FuturesExpiryStats { + const now = BigInt(Math.floor(Date.now() / 1000)); + const positions = this.positionEntries(); + const unresolved = positions + .filter((position) => position.expirationAt <= now) + .map((position) => position.expirationAt); + return { + caches: this.buckets.size, + users: this.size(), + positions: positions.length, + pastDue: unresolved.length, + oldestUnresolved: + unresolved.length === 0 + ? undefined + : unresolved.reduce((a, b) => (a < b ? a : b)), + replayFromBlock: this.replayFromBlock, + replayHeadBlock: this.replayHeadBlock, + }; + } + + /** Emergency/bootstrap path; normal discovery comes from expiry-scoped logs. */ + async seedUsers(users: readonly Address[]): Promise { + const unique = new Map(); + for (const user of users) { + const checksummed = getAddress(user); + unique.set(checksummed.toLowerCase(), checksummed); + } + for (const user of unique.values()) { + let expiries: readonly bigint[]; + try { + expiries = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates", + args: [user], + })) as readonly bigint[]; + } catch (err) { + this.logger.error({ err, user }, "futures expiry seed read failed"); + continue; + } + for (const expirationAt of expiries) { + this.touchParticipant(expirationAt, user); + await this.reconcilePosition(user, expirationAt); + } + } + } + + private watch( + eventName: + | "OrderCreated" + | "OrderMatched" + | "PositionLiquidated" + | "PositionSettled", + onLogs: (logs: readonly Log[]) => void, + ): () => void { + return this.chain.publicClient.watchContractEvent({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName, + onLogs: (logs: readonly unknown[]) => + onLogs(logs as unknown as readonly Log[]), + } as never); + } + + private async bootstrap(): Promise { + const window = await this.refreshWindow(); + if (window.targets.length === 0) return; + + const intervalSec = window.intervalDays * 86_400n; + const earliestExpiry = window.targets.reduce((a, b) => (a < b ? a : b)); + const lifetimeSec = intervalSec * BigInt(Math.max(1, window.expiryCount)); + const fromTimestamp = + earliestExpiry > lifetimeSec ? earliestExpiry - lifetimeSec : 0n; + const head = await this.chain.publicClient.getBlockNumber(); + const fromBlock = await this.findBlockAtOrAfter(fromTimestamp, head); + this.replayFromBlock = fromBlock; + this.replayHeadBlock = head; + + await this.replay(fromBlock, head); + await this.reconcileAllPositions(); + this.logger.info( + { + expiries: window.targets.map(String), + fromBlock: fromBlock.toString(), + head: head.toString(), + users: this.size(), + positions: this.positionEntries().length, + }, + "futures expiry index bootstrap complete", + ); + } + + private async refreshWindow(): Promise { + const [rawExpiries, intervalDays, expiryCount] = await Promise.all([ + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getExpirationDates", + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "expirationIntervalDays", + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "futureExpirationDatesCount", + }) as Promise, + ]); + const active = [...rawExpiries].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const intervalSec = BigInt(intervalDays) * 86_400n; + const firstActive = active[0]; + const previous = + firstActive !== undefined && firstActive >= intervalSec + ? firstActive - intervalSec + : undefined; + const targets = previous === undefined ? active : [previous, ...active]; + + this.previousExpiry = previous; + for (const expirationAt of targets) this.bucket(expirationAt); + this.pruneDrainedBuckets(); + return { targets, intervalDays: BigInt(intervalDays), expiryCount }; + } + + private pruneDrainedBuckets(): void { + if (this.previousExpiry === undefined) return; + for (const [expirationAt, bucket] of this.buckets) { + if ( + expirationAt < this.previousExpiry && + bucket.positions.size === 0 + ) { + this.buckets.delete(expirationAt); + } + } + } + + private async replay(fromBlock: bigint, head: bigint): Promise { + const chunkSize = this.config.chain.backfillChunkSize; + if (chunkSize <= 0n) throw new Error("BACKFILL_CHUNK_SIZE must be positive"); + const eventNames = [ + "OrderCreated", + "OrderMatched", + "PositionLiquidated", + "PositionSettled", + ] as const; + + for (let start = fromBlock; start <= head; start += chunkSize) { + const toBlock = + start + chunkSize - 1n > head ? head : start + chunkSize - 1n; + try { + const pages = await Promise.all( + eventNames.map(async (eventName) => { + const logs = await this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName, + fromBlock: start, + toBlock, + } as never); + return (logs as unknown as Log[]).map((log) => ({ + eventName, + log, + })); + }), + ); + const ordered = pages.flat().sort(compareLogs); + for (const entry of ordered) this.dispatch(entry.eventName, [entry.log]); + } catch (err) { + this.logger.error( + { err, fromBlock: start.toString(), toBlock: toBlock.toString() }, + "futures expiry replay chunk failed", + ); + } + } + } + + private dispatch( + eventName: + | "OrderCreated" + | "OrderMatched" + | "PositionLiquidated" + | "PositionSettled", + logs: readonly Log[], + ): void { + if (eventName === "OrderCreated") this.onOrderCreated(logs); + else if (eventName === "OrderMatched") this.onOrderMatched(logs); + else if (eventName === "PositionLiquidated") this.onPositionLiquidated(logs); + else this.onPositionSettled(logs); + } + + private onOrderCreated(logs: readonly Log[]): void { + type Args = { participant?: Address; expirationAt?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.participant === undefined || args.expirationAt === undefined) + continue; + this.touchParticipant(args.expirationAt, args.participant); + } + } + + private onOrderMatched(logs: readonly Log[]): void { + type Args = { + maker?: Address; + taker?: Address; + expirationAt?: bigint; + makerNetQtyAfter?: bigint; + takerNetQtyAfter?: bigint; + }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.expirationAt === undefined) continue; + if (args.maker !== undefined) { + this.touchParticipant(args.expirationAt, args.maker); + if (args.makerNetQtyAfter !== undefined) { + this.setPosition( + args.expirationAt, + args.maker, + args.makerNetQtyAfter !== 0n, + ); + } + } + if (args.taker !== undefined) { + this.touchParticipant(args.expirationAt, args.taker); + if (args.takerNetQtyAfter !== undefined) { + this.setPosition( + args.expirationAt, + args.taker, + args.takerNetQtyAfter !== 0n, + ); + } + } + } + } + + private onPositionLiquidated(logs: readonly Log[]): void { + type Args = { user?: Address; expirationAt?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.user === undefined || args.expirationAt === undefined) continue; + this.touchParticipant(args.expirationAt, args.user); + void this.reconcilePosition(args.user, args.expirationAt); + } + } + + private onPositionSettled(logs: readonly Log[]): void { + type Args = { user?: Address; expirationAt?: bigint }; + for (const raw of logs) { + const args = (raw as unknown as { args?: Args }).args; + if (args?.user === undefined || args.expirationAt === undefined) continue; + this.touchParticipant(args.expirationAt, args.user); + this.setPosition(args.expirationAt, args.user, false); + } + this.pruneDrainedBuckets(); + } + + private touchParticipant(expirationAt: bigint, rawUser: Address): void { + const user = getAddress(rawUser); + const existedGlobally = this.has(user); + const bucket = this.bucket(expirationAt); + bucket.participants.add(user); + if (!existedGlobally) this.emit(this.addedListeners, user); + this.emit(this.changedListeners, user); + } + + private setPosition( + expirationAt: bigint, + rawUser: Address, + active: boolean, + ): void { + const user = getAddress(rawUser); + const bucket = this.bucket(expirationAt); + const changed = active + ? !bucket.positions.has(user) + : bucket.positions.has(user); + if (active) bucket.positions.add(user); + else bucket.positions.delete(user); + if (changed) { + for (const listener of this.positionListeners) { + try { + listener(user, expirationAt, active); + } catch (err) { + this.logger.error({ err, user }, "position listener threw"); + } + } + } + } + + private bucket(expirationAt: bigint): ExpiryBucket { + let bucket = this.buckets.get(expirationAt); + if (bucket === undefined) { + bucket = { + expirationAt, + participants: new Set
(), + positions: new Set
(), + }; + this.buckets.set(expirationAt, bucket); + } + return bucket; + } + + private emit(listeners: Set, user: Address): void { + for (const listener of listeners) { + try { + listener(user); + } catch (err) { + this.logger.error({ err, user }, "participant listener threw"); + } + } + } + + private async reconcileAllPositions(): Promise { + const entries = this.positionEntries(); + for (let i = 0; i < entries.length; i += 64) { + const chunk = entries.slice(i, i + 64); + const positions = (await this.chain.publicClient.multicall({ + contracts: chunk.map((entry) => ({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition" as const, + args: [entry.user, entry.expirationAt] as const, + })), + allowFailure: false, + })) as readonly { netQuantity: bigint }[]; + for (let j = 0; j < chunk.length; j++) { + const entry = chunk[j]; + if (entry === undefined) continue; + this.setPosition( + entry.expirationAt, + entry.user, + positions[j]?.netQuantity !== 0n, + ); + } + } + } + + private async reconcilePosition( + user: Address, + expirationAt: bigint, + ): Promise { + try { + const position = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition", + args: [user, expirationAt], + })) as { netQuantity: bigint }; + this.setPosition(expirationAt, user, position.netQuantity !== 0n); + } catch (err) { + this.logger.error( + { err, user, expirationAt: expirationAt.toString() }, + "futures position reconciliation failed", + ); + } + } + + private async findBlockAtOrAfter( + timestamp: bigint, + head: bigint, + ): Promise { + let low = 0n; + let high = head; + while (low < high) { + const mid = (low + high) / 2n; + const block = await this.chain.publicClient.getBlock({ blockNumber: mid }); + if (block.timestamp < timestamp) low = mid + 1n; + else high = mid; + } + return low; + } +} + +function compareLogs( + a: { log: Log }, + b: { log: Log }, +): number { + const aBlock = a.log.blockNumber ?? 0n; + const bBlock = b.log.blockNumber ?? 0n; + if (aBlock !== bBlock) return aBlock < bBlock ? -1 : 1; + const aIndex = a.log.logIndex ?? 0; + const bIndex = b.log.logIndex ?? 0; + return aIndex - bIndex; +} + +interface ExpiryWindow { + targets: bigint[]; + intervalDays: bigint; + expiryCount: number; +} + +export interface FuturesExpiryStats { + caches: number; + users: number; + positions: number; + pastDue: number; + oldestUnresolved?: bigint; + replayFromBlock?: bigint; + replayHeadBlock?: bigint; +} diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index b5524b4..0cb92f5 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -8,9 +8,12 @@ import { import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; +import type { + ParticipantListener, + ParticipantSource, +} from "./types.ts"; import { CollateralVaultAbi as collateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; import { HashPowerPerpsDEXAbi as perpsAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; -import { HashPowerFuturesAbi as futuresAbi } from "../abi/HashPowerFutures.ts"; /** * Set of user addresses with collateral or open positions/orders that the @@ -18,7 +21,7 @@ import { HashPowerFuturesAbi as futuresAbi } from "../abi/HashPowerFutures.ts"; * * - Vault Deposited / Withdrawn / Transfer → adds users on first deposit * - Perps OrderCreated / OrderMatched / PositionLiquidated - * - Futures OrderCreated / OrderMatched / PositionLiquidated + * Futures discovery is expiry-scoped and owned by `FuturesExpiryIndex`. * * On startup, `backfill(fromBlock)` scans the same six events historically * via `getLogs` so the cold-start window doesn't miss participants who @@ -34,9 +37,9 @@ import { HashPowerFuturesAbi as futuresAbi } from "../abi/HashPowerFutures.ts"; * positions/orders. The cost of an extra `readAccountHealthBatch` call per * dormant user is far smaller than the cost of missing a re-funding event. */ -export type TrackerListener = (user: Address) => void; +export type TrackerListener = ParticipantListener; -export class ParticipantTracker { +export class ParticipantTracker implements ParticipantSource { private readonly users = new Set
(); private readonly addedListeners = new Set(); private readonly changedListeners = new Set(); @@ -97,18 +100,6 @@ export class ParticipantTracker { eventName: "OrderMatched", onLogs: (logs) => this.onPerpsOrderMatched(logs), }), - this.chain.publicClient.watchContractEvent({ - address: this.config.futures.address, - abi: futuresAbi, - eventName: "OrderCreated", - onLogs: (logs) => this.onFuturesOrderCreated(logs), - }), - this.chain.publicClient.watchContractEvent({ - address: this.config.futures.address, - abi: futuresAbi, - eventName: "OrderMatched", - onLogs: (logs) => this.onFuturesOrderMatched(logs), - }), ); } @@ -125,7 +116,7 @@ export class ParticipantTracker { } /** - * One-shot historical backfill. Scans the same six events `start()` + * One-shot historical backfill. Scans the same four events `start()` * subscribes to from `fromBlock` to the current head via `getLogs`, in * chunks of `chunkSize` blocks, and feeds each match through the same * handlers the live watcher uses. Run once at startup *after* `start()` @@ -133,10 +124,8 @@ export class ParticipantTracker { * scan head and the watcher's polling cursor is fine, because `add()` * dedupes on checksum. * - * Futures has no `getUsersWithPositions` view on-chain, so historical - * `OrderCreated` / `OrderMatched` logs are the only source of cold- - * start participants. Perps has the view but we use logs uniformly so a - * single backfill mechanism covers both venues (and the vault). + * Futures history is deliberately excluded: its bounded per-expiry replay + * lives in `FuturesExpiryIndex`. * * Webhook-only discovery mode skips backfill — Goldsky owns history in * that configuration. @@ -231,32 +220,6 @@ export class ParticipantTracker { this.onPerpsOrderMatched(logs as unknown as readonly Log[]); }, }, - { - label: "futures.OrderCreated", - run: async (from, to) => { - const logs = await this.chain.publicClient.getContractEvents({ - address: this.config.futures.address, - abi: futuresAbi, - eventName: "OrderCreated", - fromBlock: from, - toBlock: to, - }); - this.onFuturesOrderCreated(logs as unknown as readonly Log[]); - }, - }, - { - label: "futures.OrderMatched", - run: async (from, to) => { - const logs = await this.chain.publicClient.getContractEvents({ - address: this.config.futures.address, - abi: futuresAbi, - eventName: "OrderMatched", - fromBlock: from, - toBlock: to, - }); - this.onFuturesOrderMatched(logs as unknown as readonly Log[]); - }, - }, ]; for (const source of sources) { @@ -353,7 +316,7 @@ export class ParticipantTracker { * Subscribe to "user state may have changed" events. Fires for the same * triggers `onAdded` does, plus any time a tracked user's state could * have shifted (vault transfer in/out, perps OrderCreated/Matched, - * futures OrderCreated/OrderMatched). + * Futures changes are delivered by `FuturesExpiryIndex`. * * The predictive layer uses this to invalidate and rebuild a user's * cached MM snapshot. Listeners must tolerate being called for users @@ -460,28 +423,4 @@ export class ParticipantTracker { } } - /** - * `OrderCreated(bytes32 indexed orderId, address indexed participant, - * uint256 price, int256 quantity, uint256 expirationAt)`. - */ - private onFuturesOrderCreated(logs: readonly Log[]): void { - type Args = { orderId?: Hex; participant?: Address }; - for (const raw of logs) { - const args = (raw as unknown as { args?: Args }).args; - if (args?.participant !== undefined) this.touch(args.participant); - } - } - - /** - * `OrderMatched(..., address indexed maker, address indexed taker, ...)`. - */ - private onFuturesOrderMatched(logs: readonly Log[]): void { - type Args = { maker?: Address; taker?: Address }; - for (const raw of logs) { - const args = (raw as unknown as { args?: Args }).args; - if (args === undefined) continue; - if (args.maker !== undefined) this.touch(args.maker); - if (args.taker !== undefined) this.touch(args.taker); - } - } } diff --git a/keeper/src/discovery/types.ts b/keeper/src/discovery/types.ts new file mode 100644 index 0000000..0e10f3f --- /dev/null +++ b/keeper/src/discovery/types.ts @@ -0,0 +1,12 @@ +import type { Address } from "viem"; + +export type ParticipantListener = (user: Address) => void; + +/** Read/listen surface consumed by scheduling, prediction, and health. */ +export interface ParticipantSource { + list(): Address[]; + size(): number; + has(user: Address): boolean; + onAdded(listener: ParticipantListener): () => void; + onChanged(listener: ParticipantListener): () => void; +} diff --git a/keeper/src/index.ts b/keeper/src/index.ts index f3241c2..8705378 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -4,6 +4,8 @@ import { serializeError } from "./util/errSerializer.ts"; import { loadConfig } from "./config.ts"; import { createChain } from "./chain.ts"; import { ParticipantTracker } from "./discovery/tracker.ts"; +import { FuturesExpiryIndex } from "./discovery/futuresExpiryIndex.ts"; +import { CombinedParticipantSource } from "./discovery/combined.ts"; import { WebhookIngester } from "./discovery/webhook.ts"; import { CoordinatorQueue } from "./coordinator/queue.ts"; import { Planner } from "./coordinator/planner.ts"; @@ -95,13 +97,18 @@ async function main(): Promise { const notifier = new Notifier(config, logger); const tracker = new ParticipantTracker(chain, config, logger); + const futuresExpiryIndex = new FuturesExpiryIndex(chain, config, logger); + const participants = new CombinedParticipantSource([ + tracker, + futuresExpiryIndex, + ]); const queue = new CoordinatorQueue(); const planner = new Planner(chain, config, venues, logger); const executor = new CoordinatorExecutor(config, queue, planner, logger); const scheduler = new Scheduler( chain, config, - tracker, + participants, queue, executor, notifier, @@ -116,7 +123,7 @@ async function main(): Promise { const predictor = new PredictiveCoordinator( chain, config, - tracker, + participants, queue, executor, priceFeed, @@ -124,17 +131,6 @@ async function main(): Promise { notifier, ); - const health = new Healthcheck( - config, - chain.account.address, - tracker, - executor, - queue, - logger, - predictor, - priceFeed, - ); - let webhookIngester: WebhookIngester | undefined; if (config.chain.discoveryMode !== "events") { webhookIngester = new WebhookIngester(config, tracker, logger); @@ -150,9 +146,23 @@ async function main(): Promise { config, logger, ethUsdFeed, + futuresExpiryIndex, ); } + const health = new Healthcheck( + config, + chain.account.address, + participants, + executor, + queue, + logger, + predictor, + priceFeed, + futuresExpiryIndex, + deliveryCoordinator, + ); + // Always-on gas-balance monitor on the keeper signer. Logs INFO with // current balance every tick (default 5 min), and escalates to WARN / // ERROR below the configured low / critical thresholds. Built outside @@ -165,18 +175,8 @@ async function main(): Promise { // 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 // AccountHealth snapshot — that lives in the scheduler.) - tracker.onAdded((user) => { + participants.onAdded(() => { executor.kick(); - // View-based discovery: every newly-tracked user has their futures - // positions read directly from chain storage. Independent of the - // log-backfill pipeline, so it survives RPC providers that cap - // `eth_getLogs` block ranges (Alchemy free tier = 10 blocks). Without - // this hook a position created before keeper boot would only ever be - // settled if log backfill happened to find its `OrderMatched` - // event, which is unreliable on rate-limited RPCs. - if (deliveryCoordinator !== undefined) { - void deliveryCoordinator.indexUserPositions(user); - } }); // ── Graceful shutdown ───────────────────────────────────────────────── @@ -194,6 +194,7 @@ async function main(): Promise { balanceMonitor.stop(); ethUsdFeed?.stop(); deliveryCoordinator?.stop(); + futuresExpiryIndex.stop(); await executor.stop(); if (webhookIngester !== undefined) await webhookIngester.stop(); tracker.stop(); @@ -213,6 +214,7 @@ async function main(): Promise { if (ethUsdFeed !== undefined) await ethUsdFeed.start(); await predictor.start(); await tracker.start(); + await futuresExpiryIndex.start(); if (webhookIngester !== undefined) await webhookIngester.start(); if (deliveryCoordinator !== undefined) await deliveryCoordinator.start(); await executor.start(); @@ -241,51 +243,6 @@ async function main(): Promise { "BACKFILL_FROM_BLOCK unset — skipping historical scan; cold-start may miss participants until they next emit an event", ); } - if (deliveryCoordinator !== undefined) { - // Seed the delivery index from contract storage rather than logs. - // `tracker.list()` returns every user we've discovered (via webhook, - // live events, or backfill); for each we read the still-alive - // positions and schedule timers. Survives RPC providers that - // rate-limit `eth_getLogs` and is the recommended cold-start path - // for delivery — see `bootstrapFromUsers` in the coordinator. - // - // Two sources are folded in alongside the tracker: - // 1. The keeper's own signer. The validator address is also a - // legitimate participant in many deployments (see - // 0x1441…775D4 on base-sepolia: validator + buyer/seller of - // its own positions). Their positions may pre-date - // BACKFILL_FROM_BLOCK, in which case the tracker has no - // record of them — but we know the address at boot, so the - // one extra `getActiveExpirationDates` read is a free safety net. - // 2. The manual seed list (`DELIVERY_BOOTSTRAP_USERS`). Used to - // recover a known-stuck user when the tracker hasn't found - // them — typical when log backfill is failing on the - // deployed RPC, or when the user transacted before - // BACKFILL_FROM_BLOCK. - const seen = new Set(tracker.list().map((a) => a.toLowerCase())); - const seedUsers = [...tracker.list()]; - const signer = chain.account.address; - if (!seen.has(signer.toLowerCase())) { - seedUsers.push(signer); - seen.add(signer.toLowerCase()); - logger.info( - { signer }, - "delivery bootstrap: including keeper signer (not in tracker — positions may pre-date BACKFILL_FROM_BLOCK)", - ); - } - for (const u of config.delivery.bootstrapUsers) { - if (seen.has(u.toLowerCase())) continue; - seedUsers.push(u); - seen.add(u.toLowerCase()); - } - if (config.delivery.bootstrapUsers.length > 0) { - logger.info( - { count: config.delivery.bootstrapUsers.length }, - "delivery bootstrap: seeding from DELIVERY_BOOTSTRAP_USERS", - ); - } - await deliveryCoordinator.bootstrapFromUsers(seedUsers); - } await scheduler.runSweep(); // Backfill fires `tracker.onAdded` for every existing user, which the // predictor consumes via `rebuild`. Those rebuilds are fire-and-forget, @@ -296,16 +253,16 @@ async function main(): Promise { // If we discovered users but couldn't index any, something is wrong // with the snapshot path (RPC, ABI mismatch, oracle missing) — surface // it loudly. Tracker > 0 but predictor = 0 is a real outage shape. - if (tracker.size() > 0 && predictor.size() === 0) { + if (participants.size() > 0 && predictor.size() === 0) { logger.warn( - { tracked: tracker.size() }, + { tracked: participants.size() }, "tracker has users but predictor index is empty — snapshot path may be failing; check earlier 'rebuild failed' logs", ); } logger.info( { - tracked: tracker.size(), + tracked: participants.size(), predicted: predictor.size(), currentPrice: priceFeed.current()?.toString(), }, diff --git a/keeper/src/predict/coordinator.ts b/keeper/src/predict/coordinator.ts index af606ca..4fe11cf 100644 --- a/keeper/src/predict/coordinator.ts +++ b/keeper/src/predict/coordinator.ts @@ -4,7 +4,7 @@ import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import type { CoordinatorQueue } from "../coordinator/queue.ts"; import type { CoordinatorExecutor } from "../coordinator/executor.ts"; -import type { ParticipantTracker } from "../discovery/tracker.ts"; +import type { ParticipantSource } from "../discovery/types.ts"; import type { PriceFeed, PriceUpdate } from "../oracle/priceFeed.ts"; import type { Notifier } from "../alert/notifier.ts"; import { readAccountHealthBatch } from "../pme/health.ts"; @@ -58,7 +58,7 @@ export class PredictiveCoordinator { private readonly chain: Chain; private readonly config: Config; - private readonly tracker: ParticipantTracker; + private readonly tracker: ParticipantSource; private readonly queue: CoordinatorQueue; private readonly executor: CoordinatorExecutor; private readonly priceFeed: PriceFeed; @@ -68,7 +68,7 @@ export class PredictiveCoordinator { constructor( chain: Chain, config: Config, - tracker: ParticipantTracker, + tracker: ParticipantSource, queue: CoordinatorQueue, executor: CoordinatorExecutor, priceFeed: PriceFeed, diff --git a/keeper/src/runtime/healthcheck.ts b/keeper/src/runtime/healthcheck.ts index b3b0fab..67f9174 100644 --- a/keeper/src/runtime/healthcheck.ts +++ b/keeper/src/runtime/healthcheck.ts @@ -4,7 +4,9 @@ import type pino from "pino"; import type { Config } from "../config.ts"; import type { CoordinatorExecutor } from "../coordinator/executor.ts"; import type { CoordinatorQueue } from "../coordinator/queue.ts"; -import type { ParticipantTracker } from "../discovery/tracker.ts"; +import type { DeliveryCoordinator } from "../delivery/coordinator.ts"; +import type { FuturesExpiryIndex } from "../discovery/futuresExpiryIndex.ts"; +import type { ParticipantSource } from "../discovery/types.ts"; import type { PriceFeed } from "../oracle/priceFeed.ts"; import type { PredictedThresholds, PredictiveCoordinator } from "../predict/coordinator.ts"; @@ -33,22 +35,26 @@ export class Healthcheck { private readonly config: Config; private readonly signerAddress: Address; - private readonly tracker: ParticipantTracker; + private readonly tracker: ParticipantSource; private readonly executor: CoordinatorExecutor; private readonly queue: CoordinatorQueue; private readonly predictor: PredictiveCoordinator | undefined; private readonly priceFeed: PriceFeed | undefined; + private readonly futuresExpiryIndex: FuturesExpiryIndex | undefined; + private readonly deliveryCoordinator: DeliveryCoordinator | undefined; private readonly logger: pino.Logger; constructor( config: Config, signerAddress: Address, - tracker: ParticipantTracker, + tracker: ParticipantSource, executor: CoordinatorExecutor, queue: CoordinatorQueue, logger: pino.Logger, predictor?: PredictiveCoordinator, priceFeed?: PriceFeed, + futuresExpiryIndex?: FuturesExpiryIndex, + deliveryCoordinator?: DeliveryCoordinator, ) { this.config = config; this.signerAddress = signerAddress; @@ -57,6 +63,8 @@ export class Healthcheck { this.queue = queue; this.predictor = predictor; this.priceFeed = priceFeed; + this.futuresExpiryIndex = futuresExpiryIndex; + this.deliveryCoordinator = deliveryCoordinator; this.logger = logger.child({ component: "healthcheck" }); } @@ -72,6 +80,7 @@ export class Healthcheck { network: this.config.chain.network, discoveryMode: this.config.chain.discoveryMode, dryRun: String(this.config.keeper.dryRun), + deliveryEnabled: String(this.config.delivery.enabled), signer: this.signerAddress, vault: this.config.vault.address, perps: this.config.perps.address, @@ -111,6 +120,7 @@ export class Healthcheck { // and which one is it). `mmDeficit` is `|mmSurplus|` because the // queue only ever holds underwater accounts (`mmSurplus < 0`). const head = this.queue.peek(); + const expiry = this.futuresExpiryIndex?.stats(); return { executorRunning: this.executor.isRunning() ? 1 : 0, trackedUsers: this.tracker.list(), @@ -125,6 +135,15 @@ export class Healthcheck { predictedThresholds: this.predictor?.thresholds() ?? [], predictorInflight: this.predictor?.inflightUsers() ?? [], currentPrice: this.priceFeed?.current()?.toString() ?? null, + futuresExpiryCaches: expiry?.caches ?? 0, + futuresIndexedUsers: expiry?.users ?? 0, + futuresTrackedPositions: expiry?.positions ?? 0, + futuresPastDuePositions: expiry?.pastDue ?? 0, + futuresOldestUnresolvedExpiry: + expiry?.oldestUnresolved?.toString() ?? null, + futuresReplayFromBlock: expiry?.replayFromBlock?.toString() ?? null, + futuresReplayHeadBlock: expiry?.replayHeadBlock?.toString() ?? null, + deliveryTrackedPositions: this.deliveryCoordinator?.size() ?? 0, }; } diff --git a/keeper/src/runtime/scheduler.ts b/keeper/src/runtime/scheduler.ts index a8de45b..3368015 100644 --- a/keeper/src/runtime/scheduler.ts +++ b/keeper/src/runtime/scheduler.ts @@ -4,7 +4,7 @@ import type { Config } from "../config.ts"; import type { CoordinatorQueue } from "../coordinator/queue.ts"; import type { CoordinatorExecutor } from "../coordinator/executor.ts"; import type { Notifier } from "../alert/notifier.ts"; -import type { ParticipantTracker } from "../discovery/tracker.ts"; +import type { ParticipantSource } from "../discovery/types.ts"; import { readAccountHealthBatch } from "../pme/health.ts"; /** @@ -31,7 +31,7 @@ export class Scheduler { private readonly chain: Chain; private readonly config: Config; - private readonly tracker: ParticipantTracker; + private readonly tracker: ParticipantSource; private readonly queue: CoordinatorQueue; private readonly executor: CoordinatorExecutor; private readonly notifier: Notifier; @@ -40,7 +40,7 @@ export class Scheduler { constructor( chain: Chain, config: Config, - tracker: ParticipantTracker, + tracker: ParticipantSource, queue: CoordinatorQueue, executor: CoordinatorExecutor, notifier: Notifier, diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 3e447f8..65247da 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -11,6 +11,10 @@ import type pino from "pino"; import { DeliveryCoordinator, __testing } from "../../src/delivery/coordinator.ts"; import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; +import type { + FuturesExpiryIndex, + PositionListener, +} from "../../src/discovery/futuresExpiryIndex.ts"; const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; const USER_A = "0x0000000000000000000000000000000000000b0b" as Address; @@ -216,6 +220,35 @@ describe("delivery/coordinator: trackKey helpers", () => { }); describe("delivery/coordinator: event indexing", () => { + it("seeds and follows the Futures expiry index when provided", async () => { + const futureExpiry = 9_000_000_000n; + let listener: PositionListener | undefined; + const expiryIndex = { + positionEntries: () => [{ user: USER_A, expirationAt: futureExpiry }], + onPositionChanged: (next: PositionListener) => { + listener = next; + return () => { + listener = undefined; + }; + }, + } as unknown as FuturesExpiryIndex; + const coord = new DeliveryCoordinator( + makeChain({ blockTimestamp: 1n }), + makeConfig(), + silentLogger, + undefined, + expiryIndex, + ); + await coord.start(); + assert.equal(coord.has(USER_A, futureExpiry), true); + + listener?.(USER_B, futureExpiry, true); + assert.equal(coord.has(USER_B, futureExpiry), true); + listener?.(USER_A, futureExpiry, false); + assert.equal(coord.has(USER_A, futureExpiry), false); + coord.stop(); + }); + it("indexes maker+taker on OrderMatched and drops on PositionSettled", async () => { const watchers: ChainStubOptions["watchers"] = {}; const chain = makeChain({ watchers }); diff --git a/keeper/tests/discovery/combined.test.ts b/keeper/tests/discovery/combined.test.ts new file mode 100644 index 0000000..78b6859 --- /dev/null +++ b/keeper/tests/discovery/combined.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress, type Address } from "viem"; +import { CombinedParticipantSource } from "../../src/discovery/combined.ts"; +import type { + ParticipantListener, + ParticipantSource, +} from "../../src/discovery/types.ts"; + +const USER_A = "0x00000000000000000000000000000000000000A1" as Address; +const USER_B = "0x00000000000000000000000000000000000000B2" as Address; +const USER_C = "0x00000000000000000000000000000000000000C3" as Address; + +class StubSource implements ParticipantSource { + private readonly users = new Set
(); + private readonly added = new Set(); + private readonly changed = new Set(); + + constructor(users: readonly Address[]) { + for (const user of users) this.users.add(getAddress(user)); + } + + list(): Address[] { + return Array.from(this.users); + } + size(): number { + return this.users.size; + } + has(user: Address): boolean { + return this.users.has(getAddress(user)); + } + onAdded(listener: ParticipantListener): () => void { + this.added.add(listener); + return () => this.added.delete(listener); + } + onChanged(listener: ParticipantListener): () => void { + this.changed.add(listener); + return () => this.changed.delete(listener); + } + add(user: Address): void { + const checksummed = getAddress(user); + this.users.add(checksummed); + for (const listener of this.added) listener(checksummed); + } +} + +describe("CombinedParticipantSource", () => { + it("deduplicates users from perps/vault and Futures sources", () => { + const combined = new CombinedParticipantSource([ + new StubSource([USER_A, USER_B]), + new StubSource([USER_B, USER_C]), + ]); + assert.deepEqual(combined.list(), [ + getAddress(USER_A), + getAddress(USER_B), + getAddress(USER_C), + ]); + assert.equal(combined.size(), 3); + }); + + it("forwards newly discovered Futures users to listeners", () => { + const futures = new StubSource([]); + const combined = new CombinedParticipantSource([ + new StubSource([USER_A]), + futures, + ]); + const seen: Address[] = []; + const dispose = combined.onAdded((user) => seen.push(user)); + futures.add(USER_C); + assert.deepEqual(seen, [getAddress(USER_C)]); + dispose(); + }); +}); diff --git a/keeper/tests/discovery/futuresExpiryIndex.test.ts b/keeper/tests/discovery/futuresExpiryIndex.test.ts new file mode 100644 index 0000000..cc239df --- /dev/null +++ b/keeper/tests/discovery/futuresExpiryIndex.test.ts @@ -0,0 +1,226 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress, type Address } from "viem"; +import type pino from "pino"; +import type { Chain } from "../../src/chain.ts"; +import type { Config } from "../../src/config.ts"; +import { FuturesExpiryIndex } from "../../src/discovery/futuresExpiryIndex.ts"; + +const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; +const SIGNER = "0x0000000000000000000000000000000000009999" as Address; +const USER_A = "0x00000000000000000000000000000000000000A1" as Address; +const USER_B = "0x00000000000000000000000000000000000000B2" as Address; +const USER_C = "0x00000000000000000000000000000000000000C3" as Address; +const DAY = 86_400n; +const ACTIVE_A = 1_000_000n; +const ACTIVE_B = ACTIVE_A + DAY; +const PREVIOUS = ACTIVE_A - DAY; + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +interface StubState { + activeExpiries: bigint[]; + watchers: Record void>; +} + +function makeConfig(): Config { + return { + chain: { backfillChunkSize: 1_000n }, + futures: { address: FUTURES }, + delivery: { + bootstrapUsers: [], + sweepIntervalMs: 1_000_000, + }, + } as unknown as Config; +} + +function makeChain(state: StubState): Chain { + return { + account: { address: SIGNER }, + publicClient: { + watchContractEvent: ({ + eventName, + onLogs, + }: { + eventName: string; + onLogs: (logs: readonly unknown[]) => void; + }) => { + state.watchers[eventName] = onLogs; + return () => undefined; + }, + readContract: async ({ + functionName, + args, + }: { + functionName: string; + args?: readonly unknown[]; + }) => { + if (functionName === "getExpirationDates") return state.activeExpiries; + if (functionName === "expirationIntervalDays") return 1; + if (functionName === "futureExpirationDatesCount") return 2; + if (functionName === "getActiveExpirationDates") return []; + if (functionName === "getUserPosition") { + const user = getAddress(args?.[0] as Address); + return { + netQuantity: user === getAddress(USER_A) ? 1n : 0n, + netEntryValue: 1n, + }; + } + throw new Error(`unexpected readContract ${functionName}`); + }, + getBlockNumber: async () => 100n, + getBlock: async ({ blockNumber }: { blockNumber?: bigint } = {}) => ({ + timestamp: (blockNumber ?? 100n) * 10_000n, + }), + getContractEvents: async ({ eventName }: { eventName: string }) => { + if (eventName === "OrderCreated") { + return [ + { + blockNumber: 80n, + logIndex: 0, + args: { participant: USER_C, expirationAt: ACTIVE_A }, + }, + ]; + } + if (eventName === "OrderMatched") { + return [ + { + blockNumber: 81n, + logIndex: 0, + args: { + maker: USER_A, + taker: USER_B, + expirationAt: PREVIOUS, + makerNetQtyAfter: 1n, + takerNetQtyAfter: -1n, + }, + }, + ]; + } + if (eventName === "PositionSettled") { + return [ + { + blockNumber: 82n, + logIndex: 0, + args: { user: USER_B, expirationAt: PREVIOUS }, + }, + ]; + } + return []; + }, + multicall: async ({ contracts }: { contracts: readonly unknown[] }) => + contracts.map(() => ({ netQuantity: 1n, netEntryValue: 1n })), + }, + } as unknown as Chain; +} + +describe("FuturesExpiryIndex", () => { + it("replays active and previous expiries and partitions participants", async () => { + const state: StubState = { + activeExpiries: [ACTIVE_A, ACTIVE_B], + watchers: {}, + }; + const index = new FuturesExpiryIndex( + makeChain(state), + makeConfig(), + silentLogger, + ); + await index.start(); + + assert.equal(index.has(USER_A), true); + assert.equal(index.has(USER_B), true); + assert.equal(index.has(USER_C), true); + assert.deepEqual(index.positionEntries(), [ + { user: getAddress(USER_A), expirationAt: PREVIOUS }, + ]); + assert.equal(index.stats().caches, 3); + assert.equal(index.stats().replayHeadBlock, 100n); + index.stop(); + }); + + it("updates position candidates from live matches and settlements", async () => { + const state: StubState = { + activeExpiries: [ACTIVE_A], + watchers: {}, + }; + const index = new FuturesExpiryIndex( + makeChain(state), + makeConfig(), + silentLogger, + ); + await index.start(); + + state.watchers.OrderMatched?.([ + { + args: { + maker: USER_A, + taker: USER_B, + expirationAt: ACTIVE_A, + makerNetQtyAfter: 2n, + takerNetQtyAfter: -2n, + }, + }, + ]); + assert.equal( + index.positionEntries().filter((entry) => entry.expirationAt === ACTIVE_A) + .length, + 2, + ); + + state.watchers.PositionSettled?.([ + { args: { user: USER_A, expirationAt: ACTIVE_A } }, + ]); + assert.equal( + index + .positionEntries() + .some( + (entry) => + entry.user === getAddress(USER_A) && + entry.expirationAt === ACTIVE_A, + ), + false, + ); + index.stop(); + }); + + it("retains an older expiry while it still has an unresolved position", async () => { + const state: StubState = { + activeExpiries: [ACTIVE_A], + watchers: {}, + }; + const index = new FuturesExpiryIndex( + makeChain(state), + makeConfig(), + silentLogger, + ); + await index.start(); + + state.watchers.OrderMatched?.([ + { + args: { + maker: USER_A, + taker: USER_B, + expirationAt: PREVIOUS, + makerNetQtyAfter: 1n, + takerNetQtyAfter: 0n, + }, + }, + ]); + state.activeExpiries = [ACTIVE_B]; + await index.refresh(); + + assert.equal( + index + .positionEntries() + .some((entry) => entry.expirationAt === PREVIOUS), + true, + ); + index.stop(); + }); +}); diff --git a/keeper/tests/discovery/tracker.test.ts b/keeper/tests/discovery/tracker.test.ts index 0a75f06..3cead10 100644 --- a/keeper/tests/discovery/tracker.test.ts +++ b/keeper/tests/discovery/tracker.test.ts @@ -155,7 +155,7 @@ describe("ParticipantTracker: onAdded listeners", () => { }); describe("ParticipantTracker: backfill", () => { - it("ingests participants from every event source across all chunks", async () => { + it("ingests vault and perps participants across all chunks", async () => { // Each mocked log shape mirrors what viem's getContractEvents would // hand to our handlers — only `args` is read. Note the perps/futures // OrderCreated logs go to separate handlers keyed by contract address, @@ -177,19 +177,13 @@ describe("ParticipantTracker: backfill", () => { [scriptKey(config.perps.address, "OrderMatched")]: [ { args: { maker: userAt(4), taker: userAt(5) } }, ], - [scriptKey(config.futures.address, "OrderCreated")]: [ - { args: { participant: userAt(6) } }, - ], - [scriptKey(config.futures.address, "OrderMatched")]: [ - { args: { maker: userAt(7), taker: userAt(8) } }, - ], }, }), config, silentLogger, ); await t.backfill(0n, 500n); - assert.equal(t.size(), 9); + assert.equal(t.size(), 6); }); it("chunks the block range and calls getContractEvents per chunk", async () => { @@ -211,8 +205,8 @@ describe("ParticipantTracker: backfill", () => { } as unknown as Chain; const t = new ParticipantTracker(chain, makeConfig(), silentLogger); await t.backfill(0n, 1000n); - // 6 sources × 3 chunks ([0,999], [1000,1999], [2000,2500]) = 18 calls. - assert.equal(calls.length, 18); + // 4 sources × 3 chunks ([0,999], [1000,1999], [2000,2500]) = 12 calls. + assert.equal(calls.length, 12); // Spot-check the chunk boundary clamping on the last page. const deposited = calls.filter((c) => c.eventName === "Deposited"); assert.deepEqual( @@ -242,7 +236,7 @@ describe("ParticipantTracker: backfill", () => { } as unknown as Chain; const t = new ParticipantTracker(chain, makeConfig(), silentLogger); await t.backfill(0n, 1000n); - assert.ok(calls >= 6, "all six sources attempted despite Deposited failure"); + assert.ok(calls >= 4, "all four sources attempted despite Deposited failure"); // Transfer still ingested. assert.equal(t.size(), 2); }); diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 26983cb..047de12 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -14,6 +14,9 @@ import { hardhat } from "viem/chains"; import type { Config } from "../../src/config.ts"; import type { Chain } from "../../src/chain.ts"; import { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import { FuturesExpiryIndex } from "../../src/discovery/futuresExpiryIndex.ts"; +import { CombinedParticipantSource } from "../../src/discovery/combined.ts"; +import type { ParticipantSource } from "../../src/discovery/types.ts"; import { CoordinatorQueue } from "../../src/coordinator/queue.ts"; import { Planner } from "../../src/coordinator/planner.ts"; import { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; @@ -46,6 +49,8 @@ export interface KeeperHarness { config: Config; chain: Chain; tracker: ParticipantTracker; + futuresExpiryIndex: FuturesExpiryIndex; + participants: ParticipantSource; queue: CoordinatorQueue; planner: Planner; executor: CoordinatorExecutor; @@ -122,13 +127,18 @@ export function buildKeeper( const notifier = new Notifier(config, log); const tracker = new ParticipantTracker(chain, config, log); + const futuresExpiryIndex = new FuturesExpiryIndex(chain, config, log); + const participants = new CombinedParticipantSource([ + tracker, + futuresExpiryIndex, + ]); const queue = new CoordinatorQueue(); const planner = new Planner(chain, config, venues, log); const executor = new CoordinatorExecutor(config, queue, planner, log); const scheduler = new Scheduler( chain, config, - tracker, + participants, queue, executor, notifier, @@ -145,7 +155,7 @@ export function buildKeeper( const predictor = new PredictiveCoordinator( chain, config, - tracker, + participants, queue, executor, priceFeed, @@ -155,14 +165,20 @@ export function buildKeeper( // Newly-tracked users wake idle workers — same edge `keeper/src/index.ts` // wires in production. - tracker.onAdded(() => executor.kick()); + participants.onAdded(() => executor.kick()); // Optional delivery coordinator — opt-in per test. Built but not started; // start() below boots it after the live tracker is up so it sees the same // event ordering production does. const delivery = overrides.delivery === true - ? new DeliveryCoordinator(chain, config, log) + ? new DeliveryCoordinator( + chain, + config, + log, + undefined, + futuresExpiryIndex, + ) : undefined; let started = false; @@ -170,6 +186,8 @@ export function buildKeeper( config, chain, tracker, + futuresExpiryIndex, + participants, queue, planner, executor, @@ -184,6 +202,7 @@ export function buildKeeper( await priceFeed.start(); await predictor.start(); await tracker.start(); + await futuresExpiryIndex.start(); if (delivery !== undefined) await delivery.start(); await executor.start(); // Scheduler is NOT started: tests drive it manually via @@ -196,6 +215,7 @@ export function buildKeeper( predictor.stop(); priceFeed.stop(); delivery?.stop(); + futuresExpiryIndex.stop(); await executor.stop(); tracker.stop(); }, diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 20c1563..580b560 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -26,7 +26,7 @@ import { PerpsPositionAbi } from "../../src/venues/perpsPositionAbi.ts"; // ───────────────────────────────────────────────────────────────────────── /** - * `ParticipantTracker` is event-driven — there's a small RPC-poll delay + * Participant discovery is event-driven — there's a small RPC-poll delay * between a user's first on-chain action and the keeper "knowing" about * them. Every test that wants the keeper to act on `user` must call this * first, otherwise `runSweep` / `planner.run` will short-circuit on an @@ -37,7 +37,7 @@ export async function discoverUser( user: Address, timeoutMs = 10_000, ): Promise { - await waitFor(() => keeper.tracker.has(user), timeoutMs); + await waitFor(() => keeper.participants.has(user), timeoutMs); } /** diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index e991d55..898fd49 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -954,11 +954,17 @@ describe("DeliveryCoordinator (live RPC)", () => { BigInt(ctx.aliceFuturesQty), ); - // Seed the delivery index from history — the positions were created - // before the keeper booted, so the live watcher hasn't seen them. - await keeper.delivery.backfill(0n, 10_000n); + // The position predates keeper startup. Expiry-scoped replay must + // discover it and seed delivery without a deployment-wide backfill. for (const id of positionsBefore) { - assert.ok(keeper.delivery.has(alice, BigInt(id)), `backfill should index position ${id}`); + assert.ok( + keeper.futuresExpiryIndex.has(alice), + `expiry replay should discover ${alice}`, + ); + assert.ok( + keeper.delivery.has(alice, BigInt(id)), + `expiry replay should index position ${id}`, + ); } // Fast-forward past `expirationAt`. `settlePosition` requires diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index 0236ba2..d22c924 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -7,6 +7,8 @@ import type { Config } from "../../src/config.ts"; import type { CoordinatorExecutor } from "../../src/coordinator/executor.ts"; import type { CoordinatorQueue } from "../../src/coordinator/queue.ts"; import type { ParticipantTracker } from "../../src/discovery/tracker.ts"; +import type { FuturesExpiryIndex } from "../../src/discovery/futuresExpiryIndex.ts"; +import type { DeliveryCoordinator } from "../../src/delivery/coordinator.ts"; import type { PriceFeed } from "../../src/oracle/priceFeed.ts"; import type { PredictedThresholds, @@ -198,6 +200,46 @@ describe("runtime/healthcheck: snapshot", () => { assert.equal(snap.queueHeadUser, null); assert.equal(snap.queueHeadMmDeficit, 0); }); + + it("reports Futures expiry and delivery indexing state", () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: true, + inflight: 0, + }); + const expiryIndex = { + stats: () => ({ + caches: 3, + users: 7, + positions: 2, + pastDue: 1, + oldestUnresolved: 1_787_832_000n, + replayFromBlock: 40_000_000n, + replayHeadBlock: 46_000_000n, + }), + } as unknown as FuturesExpiryIndex; + const delivery = { size: () => 2 } as unknown as DeliveryCoordinator; + const hc = new Healthcheck( + config, + SIGNER, + tracker, + executor, + queue, + silentLogger, + undefined, + undefined, + expiryIndex, + delivery, + ); + const snap = hc.snapshot(); + assert.equal(snap.futuresExpiryCaches, 3); + assert.equal(snap.futuresIndexedUsers, 7); + assert.equal(snap.futuresTrackedPositions, 2); + assert.equal(snap.futuresPastDuePositions, 1); + assert.equal(snap.futuresOldestUnresolvedExpiry, "1787832000"); + assert.equal(snap.futuresReplayFromBlock, "40000000"); + assert.equal(snap.futuresReplayHeadBlock, "46000000"); + assert.equal(snap.deliveryTrackedPositions, 2); + }); }); describe("runtime/healthcheck: info", () => { @@ -212,6 +254,7 @@ describe("runtime/healthcheck: info", () => { network: "hardhat", discoveryMode: "events", dryRun: "false", + deliveryEnabled: "false", signer: SIGNER, vault: config.vault.address, perps: config.perps.address, From 61693aabd8d5ef6550697a05d81c7b05236db64e Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 1 Sep 2026 16:30:12 +0200 Subject: [PATCH 171/180] fix(keeper): expose liveness during startup Keep ECS health checks responsive while historical indexing runs, and gate readiness until the initial sweep completes. --- keeper/README.md | 4 +- keeper/src/index.ts | 5 +- keeper/src/runtime/healthcheck.ts | 64 +++++++++++++++++++++--- keeper/tests/runtime/healthcheck.test.ts | 30 +++++++++++ 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/keeper/README.md b/keeper/README.md index 48b9795..36fa5de 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -109,7 +109,7 @@ src/ liquidate.ts # Shared simulate → send → parse-fee + revert-decoding helper runtime/ scheduler.ts # Periodic safety-net sweep over the tracker's user set - healthcheck.ts # GET /health for k8s/ECS liveness probes + healthcheck.ts # GET /health liveness + GET /ready readiness probes scripts/ sync-abis.ts # Copies sibling-package ABIs into src/abi/ @@ -168,7 +168,7 @@ See `src/config.ts` for the authoritative shape. The minimum-viable set: | `COORDINATOR_CONFIRMATION_BLOCKS` | no | Block confirmations after each tx. Default `1` | | `KEEPER_MIN_PROFIT_MARGIN` | no | Bail on plans that would net ≤ this in token decimals. Default `0` | | `SWEEP_INTERVAL_MS` | no | Periodic safety-net sweep cadence (predictor handles the hot path). Default `60_000` | -| `HEALTH_PORT` | no | `GET /health` port. Default `3000` | +| `HEALTH_PORT` | no | `/health` and `/ready` port. Default `3000` | | `LOG_LEVEL` | no | pino level. Default `info` | ## Dry run diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 8705378..9fad1af 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -204,6 +204,9 @@ async function main(): Promise { process.on("SIGTERM", () => void shutdown("SIGTERM")); // ── Start ───────────────────────────────────────────────────────────── + // Bind liveness before any historical replay. ECS must be able to observe + // a healthy "booting" process while expiry discovery catches up. + health.start(); // PriceFeed first: primes `current()` with one read so the predictor has // a baseline before the first tracker event fires. Predictor next so its // tracker hooks are in place before tracker.start() flushes any backlog. @@ -219,7 +222,6 @@ async function main(): Promise { if (deliveryCoordinator !== undefined) await deliveryCoordinator.start(); await executor.start(); scheduler.start(); - health.start(); // Eager initial check (logs the boot-time balance) + interval polling. // Started after the venues so a startup failure earlier doesn't leave // a phantom monitor running. @@ -249,6 +251,7 @@ async function main(): Promise { // so we wait until `inflightRebuilds` drains before claiming "running" // — otherwise the first health probe can race a half-built index. await predictor.awaitIdle(); + health.markReady(); // If we discovered users but couldn't index any, something is wrong // with the snapshot path (RPC, ABI mismatch, oracle missing) — surface diff --git a/keeper/src/runtime/healthcheck.ts b/keeper/src/runtime/healthcheck.ts index 67f9174..d326639 100644 --- a/keeper/src/runtime/healthcheck.ts +++ b/keeper/src/runtime/healthcheck.ts @@ -13,7 +13,9 @@ import type { PredictedThresholds, PredictiveCoordinator } from "../predict/coor /** * Health and metrics surface for the keeper. * - * GET /health liveness probe (200 ok / 503 degraded). Body holds the + * GET /health liveness probe (200 while booting/ready, 503 degraded). + * GET /ready readiness probe (503 until startup indexing completes). + * The `/health` body holds the * full snapshot — counters AND per-user address lists * (`trackedUsers`, `predictedUsers`, `predictorInflight`, * `underwater`) — so a single `curl :3000/health | jq` @@ -22,16 +24,17 @@ import type { PredictedThresholds, PredictiveCoordinator } from "../predict/coor * Address lists are reduced to their `length` (gauge) * here so we don't blow up Prometheus cardinality. * - * Health flips to 503 when the executor isn't running (event watcher - * silently dropped, executor stopped) so the orchestrator (k8s, ECS) - * restarts the pod. Metrics are exposed unconditionally — useful even - * when the keeper is degraded. + * Health remains live during bounded startup replay, then flips to 503 if + * startup times out or the ready executor stops. Metrics are exposed + * unconditionally — useful even when the keeper is booting or degraded. * * Predictor metrics are optional so this module remains usable for the * legacy boot path that doesn't have one. */ export class Healthcheck { private server: Server | undefined; + private lifecycle: "booting" | "ready" | "degraded" = "booting"; + private startupTimer: NodeJS.Timeout | undefined; private readonly config: Config; private readonly signerAddress: Address; @@ -121,7 +124,10 @@ export class Healthcheck { // queue only ever holds underwater accounts (`mmSurplus < 0`). const head = this.queue.peek(); const expiry = this.futuresExpiryIndex?.stats(); + const ready = this.lifecycle === "ready" && this.executor.isRunning(); return { + ready: ready ? 1 : 0, + lifecycle: this.lifecycle, executorRunning: this.executor.isRunning() ? 1 : 0, trackedUsers: this.tracker.list(), inflight: this.executor.inflightCount(), @@ -148,19 +154,44 @@ export class Healthcheck { } start(): void { + if (this.server !== undefined) return; + this.lifecycle = "booting"; + this.startupTimer = setTimeout(() => { + if (this.lifecycle !== "booting") return; + this.lifecycle = "degraded"; + this.logger.error( + { timeoutMs: STARTUP_TIMEOUT_MS }, + "keeper startup timed out before readiness", + ); + }, STARTUP_TIMEOUT_MS); + if (typeof this.startupTimer.unref === "function") this.startupTimer.unref(); + this.server = createServer((req, res) => { if (req.url === "/health") { - const ok = this.executor.isRunning(); + const booting = this.lifecycle === "booting"; + const ok = + booting || + (this.lifecycle === "ready" && this.executor.isRunning()); + const status = booting ? "booting" : ok ? "ok" : "degraded"; res.writeHead(ok ? 200 : 503, { "content-type": "application/json" }); res.end( JSON.stringify({ - status: ok ? "ok" : "degraded", + status, info: this.info(), ...this.snapshot(), }), ); return; } + if (req.url === "/ready") { + const ready = + this.lifecycle === "ready" && this.executor.isRunning(); + res.writeHead(ready ? 200 : 503, { + "content-type": "application/json", + }); + res.end(JSON.stringify({ ready })); + return; + } if (req.url === "/metrics") { res.writeHead(200, { "content-type": "text/plain; version=0.0.4" }); res.end(this.renderPrometheus()); @@ -175,12 +206,29 @@ export class Healthcheck { } async stop(): Promise { + if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); + this.startupTimer = undefined; if (this.server === undefined) return; const srv = this.server; this.server = undefined; await new Promise((resolve) => srv.close(() => resolve())); } + /** Mark startup indexing and the initial safety sweep complete. */ + markReady(): void { + this.lifecycle = "ready"; + if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); + this.startupTimer = undefined; + this.logger.info("keeper readiness reached"); + } + + /** Force liveness into a restartable degraded state. */ + markDegraded(): void { + this.lifecycle = "degraded"; + if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); + this.startupTimer = undefined; + } + /** * Minimal Prometheus exposition. Each metric uses a `keeper_` prefix to * namespace it from system metrics. Address-list snapshot fields are @@ -240,6 +288,8 @@ interface UnderwaterEntry { mmDeficit: string; } +const STARTUP_TIMEOUT_MS = 10 * 60 * 1000; + function snakeCase(camel: string): string { return camel.replace(/([A-Z])/g, "_$1").toLowerCase(); } diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index d22c924..8dda8dd 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -320,6 +320,7 @@ async function withServer( priceFeed, ); hc.start(); + hc.markReady(); try { return await fn(portOf(hc)); } finally { @@ -347,6 +348,10 @@ describe("runtime/healthcheck: HTTP endpoints", () => { assert.equal(info.signer, SIGNER); assert.equal(info.vault, STUB_CONFIG.vault.address); assert.equal(info.perps, STUB_CONFIG.perps.address); + + const ready = await fetch(`http://127.0.0.1:${port}/ready`); + assert.equal(ready.status, 200); + assert.deepEqual(await ready.json(), { ready: true }); }); }); @@ -375,6 +380,30 @@ describe("runtime/healthcheck: HTTP endpoints", () => { assert.equal(res.status, 404); }); }); + + it("serves liveness while booting and gates readiness", async () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: false, + inflight: 0, + }); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); + hc.start(); + const port = portOf(hc); + try { + const health = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(health.status, 200); + assert.equal( + ((await health.json()) as Record).status, + "booting", + ); + + const ready = await fetch(`http://127.0.0.1:${port}/ready`); + assert.equal(ready.status, 503); + assert.deepEqual(await ready.json(), { ready: false }); + } finally { + await hc.stop(); + } + }); }); describe("runtime/healthcheck: degraded executor", () => { @@ -385,6 +414,7 @@ describe("runtime/healthcheck: degraded executor", () => { }); const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); hc.start(); + hc.markReady(); const port = portOf(hc); try { const res = await fetch(`http://127.0.0.1:${port}/health`); From c7da87f8bfbf8c8a8a5553a029b897c7708e35ee Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 15 Sep 2026 13:37:09 +0200 Subject: [PATCH 172/180] feat(contracts): add Hardhat --env loader plugin Load per-environment config from config/.env with machine-local override files, and wire npm scripts to --env. --- config/dev.env | 24 ++++ config/prd.env | 13 +++ config/stg.env | 1 + contracts/.gitignore | 3 +- contracts/hardhat.config.ts | 15 ++- contracts/lib/env.ts | 3 +- contracts/package.json | 9 +- contracts/plugins/env-loader/config-hooks.ts | 109 ++++++++++++++++++ contracts/plugins/env-loader/index.ts | 25 ++++ .../plugins/env-loader/type-extensions.ts | 18 +++ 10 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 config/dev.env create mode 100644 config/prd.env create mode 100644 config/stg.env create mode 100644 contracts/plugins/env-loader/config-hooks.ts create mode 100644 contracts/plugins/env-loader/index.ts create mode 100644 contracts/plugins/env-loader/type-extensions.ts diff --git a/config/dev.env b/config/dev.env new file mode 100644 index 0000000..6c99eb4 --- /dev/null +++ b/config/dev.env @@ -0,0 +1,24 @@ +BTC_USD_FEED_ADDRESS="0x37b5e07c59238ad3bb11ac27129387a67f3340b6" +COLLATERAL_TOKEN_ADDRESS="0xdd15eed84065a58c9e9ff9e95fb996be0fff22aa" +FUTURES_ADDRESS="0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479" +HASHPRICE_USD_ADDRESS="0x865c4fb61b85cda3d39a94d4e8de6962f7626c4d" +HOOK_ADDRESS="0x99c28ff216a80e1a14ff276775ec458150979959" +PERPS_ADDRESS="0x0d412BC34a48e434144687Aac03b9C593F5237B6" +PME_ADDRESS="0x3899e429ef47140ec46c6e23f04253c24f221b69" +POINTS_ADDRESS="0x153F6cb4386d717AD94791E6Ee8ae37f80315972" +VAULT_ADDRESS="0x54a79e2a5c60ace37b280ebbcda51b4e903d25f0" +# OPTIONS_ADDRESS="" +SAFE_OWNER_ADDRESS= + +IM_SPOT_SHOCK=100000000000000000 +MM_SPOT_SHOCK=100000000000000000 +IM_VOL_SHOCK=50000000000000000 +MM_VOL_SHOCK=50000000000000000 + +NETWORK=base-sepolia +HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-oracles/dev-latest/gn +BACKFILL_FROM_BLOCK=40113440 +DELIVERY_KEEPER_ENABLED=true + + + diff --git a/config/prd.env b/config/prd.env new file mode 100644 index 0000000..40d5339 --- /dev/null +++ b/config/prd.env @@ -0,0 +1,13 @@ +NETWORK=base-mainnet + +COLLATERAL_TOKEN_ADDRESS="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +PME_ADDRESS="0x5F047CCE438ae7796140506a5edf3D711034aaF3" +POINTS_ADDRESS="0x52e1b275d7f925e48f74d304e6d7e8ca489de6b9" +POINTS_HOOK_ADDRESS="0x81f47f6c84ffb1a5daa8c54989a4cb9458017188" +SAFE_OWNER_ADDRESS="0x57ac51Ad8b3B5a95e655eD2AF98D9881B136f924" +VAULT_ADDRESS="0x0730422E49B76A2D36d51304ACEcbe4f444821F8" + +IM_SPOT_SHOCK=100000000000000000 +MM_SPOT_SHOCK=100000000000000000 +IM_VOL_SHOCK=50000000000000000 +MM_VOL_SHOCK=50000000000000000 diff --git a/config/stg.env b/config/stg.env new file mode 100644 index 0000000..6a8cdb0 --- /dev/null +++ b/config/stg.env @@ -0,0 +1 @@ +# we skipped stg environment to avoid maintaining two liquidity pools diff --git a/contracts/.gitignore b/contracts/.gitignore index 387c22a..8b27c0f 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -5,5 +5,4 @@ cache *.tsbuildinfo # Deploy script output (deployed addresses) -points-addr.tmp -points-hook-addr.tmp +*.tmp diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 10e576b..71069ac 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -1,13 +1,16 @@ import { configVariable, defineConfig } from "hardhat/config"; import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem"; import hardhatViemAbi from "hardhat-viem-abi"; -import { tryLoadEnvFile } from "./lib/env.ts"; - -tryLoadEnvFile("./../.env"); -tryLoadEnvFile(".env"); +import envLoader from "./plugins/env-loader/index.ts"; export default defineConfig({ - plugins: [hardhatToolboxViem, hardhatViemAbi], + plugins: [hardhatToolboxViem, hardhatViemAbi, envLoader], + envLoader: { + configDir: "../config", + // Machine/secret values; win over the named env file for overlapping keys. + overrideEnvFiles: ["../.env", ".env"], + }, + codegen: { // Keepers and the UI install `abi/` as this package name; do not rename casually. packageJson: { name: "collateral-margin-abi" }, @@ -50,7 +53,7 @@ export default defineConfig({ etherscan: { apiKey: configVariable("ETHERSCAN_API_KEY"), enabled: true, - }, + } }, networks: { hardhat: { diff --git a/contracts/lib/env.ts b/contracts/lib/env.ts index 6f17aef..0910ccf 100644 --- a/contracts/lib/env.ts +++ b/contracts/lib/env.ts @@ -16,8 +16,9 @@ export function requireEnvsSet( export function tryLoadEnvFile(path: string): void { try { loadEnvFile(path); + console.info(`Loaded env file ${path}`); } catch (err: unknown) { - console.info(`Failed to load env file ${path}:\n${(err as Error).message}`); + console.info(`Env file ${path} not loaded: ${(err as Error).message}`); } } diff --git a/contracts/package.json b/contracts/package.json index 4c1b8f2..601ee3e 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -12,12 +12,9 @@ "typecheck": "tsgo --noEmit", "lint": "biome lint .", "clean": "rm -rf abi artifacts cache", - "deploy:vault": "hardhat run scripts/deploy-collateral-vault.ts", - "deploy:pme": "hardhat run scripts/deploy-portfolio-margin-engine.ts", - "deploy:points": "hardhat run scripts/deploy-points.ts", - "deploy:points-hook": "hardhat run scripts/deploy-points-hook.ts", - "upgrade:vault": "hardhat run scripts/update-collateral-vault.ts", - "upgrade:pme": "hardhat run scripts/update-portfolio-margin-engine.ts" + "run:dev": "hardhat run --env dev", + "run:stg": "hardhat run --env stg", + "run:prod": "hardhat run --env prd" }, "devDependencies": { "@biomejs/biome": "^2.4.10", diff --git a/contracts/plugins/env-loader/config-hooks.ts b/contracts/plugins/env-loader/config-hooks.ts new file mode 100644 index 0000000..89cad96 --- /dev/null +++ b/contracts/plugins/env-loader/config-hooks.ts @@ -0,0 +1,109 @@ +import { existsSync, readdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { loadEnvFile } from "node:process"; +import type { ConfigHooks } from "hardhat/types/hooks"; +import "./type-extensions.ts"; + +/** + * The npm package holding this plugin, which is also the Hardhat project root. + * Resolved from this file so that every path is independent of the cwd. + */ +function findProjectRoot(): string { + let dir = import.meta.dirname; + while (!existsSync(resolve(dir, "package.json"))) { + const parent = dirname(dir); + if (parent === dir) + throw new Error("env-loader: no package.json above the plugin"); + dir = parent; + } + return dir; +} + +/** Every `.env` file in `configDir`, by name. */ +function availableEnvs(configDir: string): string[] { + try { + return readdirSync(configDir) + .filter((file) => file.endsWith(".env")) + .map((file) => file.slice(0, -".env".length)) + .sort(); + } catch { + return []; + } +} + +/** Reads the env name from a `--env ` or `--env=` argument. */ +function readEnvFlag(argv: string[], configDir: string): string | undefined { + const index = argv.findIndex( + (arg) => arg === "--env" || arg.startsWith("--env="), + ); + if (index === -1) return undefined; + const arg = argv[index]; + const name = arg.startsWith("--env=") + ? arg.slice("--env=".length) + : argv[index + 1]; + const known = availableEnvs(configDir); + if (name === undefined || !known.includes(name)) + throw new Error( + `--env must name a file in ${configDir}, one of ${known.join(", ") || "(none found)"}, got ${name ?? "nothing"}`, + ); + return name; +} + +/** + * Loads the env files for the environment named by `--env`, and selects that + * environment's network so scripts cannot be pointed at the wrong chain by + * accident. + * + * `loadEnvFile` never overwrites a variable that is already set, so files are + * read most-specific first: `overrideEnvFiles` (machine/secret), then the + * named env file. The real process environment always wins. + */ +export function loadEnv( + configDir: string, + overrideEnvFiles: string[], + projectRoot: string, + argv = process.argv, +): void { + for (const file of overrideEnvFiles) { + tryLoadEnvFile(resolve(projectRoot, file)); + } + const name = readEnvFlag(argv, configDir); + if (name !== undefined) tryLoadEnvFile(resolve(configDir, `${name}.env`)); + if (name === undefined) return; + + const network = process.env.NETWORK; + if (!network) throw new Error(`${name}.env must set NETWORK`); + // An explicit `--network` still wins: Hardhat prefers CLI args over env vars. + process.env.HARDHAT_NETWORK ??= network; +} + +export default async (): Promise> => ({ + // This is the earliest hook Hardhat runs, and crucially it runs before + // global options are resolved, so `HARDHAT_NETWORK` is still read from here. + async extendUserConfig(config, next) { + // The config file is loaded untypechecked, so this is worth stating plainly. + const { configDir, overrideEnvFiles } = config.envLoader ?? {}; + if (typeof configDir !== "string") + throw new Error("envLoader.configDir is required and must be a string"); + if ( + !Array.isArray(overrideEnvFiles) || + !overrideEnvFiles.every((p) => typeof p === "string") + ) + throw new Error( + "envLoader.overrideEnvFiles is required and must be an array of strings", + ); + + const projectRoot = findProjectRoot(); + loadEnv(resolve(projectRoot, configDir), overrideEnvFiles, projectRoot); + return next(config); + }, +}); + +export function tryLoadEnvFile(path: string): void { + try { + loadEnvFile(path); + console.info(`Loaded env file ${path}`); + } catch (err: unknown) { + console.info(`Env file ${path} not loaded: ${(err as Error).message}`); + } +} diff --git a/contracts/plugins/env-loader/index.ts b/contracts/plugins/env-loader/index.ts new file mode 100644 index 0000000..8f8f5e3 --- /dev/null +++ b/contracts/plugins/env-loader/index.ts @@ -0,0 +1,25 @@ +import { globalOption } from "hardhat/config"; +import { ArgumentType } from "hardhat/types/arguments"; +import type { HardhatPlugin } from "hardhat/types/plugins"; +import "./type-extensions.ts"; + +/** + * For `--env `, loads `envLoader.overrideEnvFiles` then `.env` + * from `envLoader.configDir`, and connects to the network named by its `NETWORK`. + */ +const envLoaderPlugin: HardhatPlugin = { + id: "env-loader", + globalOptions: [ + globalOption({ + name: "env", + description: "The environment to load .env for", + type: ArgumentType.STRING_WITHOUT_DEFAULT, + defaultValue: undefined, + }), + ], + hookHandlers: { + config: () => import("./config-hooks.ts"), + }, +}; + +export default envLoaderPlugin; diff --git a/contracts/plugins/env-loader/type-extensions.ts b/contracts/plugins/env-loader/type-extensions.ts new file mode 100644 index 0000000..b0d05fb --- /dev/null +++ b/contracts/plugins/env-loader/type-extensions.ts @@ -0,0 +1,18 @@ +import "hardhat/types/config"; + +declare module "hardhat/types/config" { + interface EnvLoaderUserConfig { + /** Directory holding the `.env` files, relative to the project root. */ + configDir: string; + /** + * Machine-specific or secret `.env` files, relative to the project root. + * Loaded before the named env file so their values win for overlapping keys + * (`loadEnvFile` never overwrites an already-set variable). + */ + overrideEnvFiles: string[]; + } + + interface HardhatUserConfig { + envLoader?: EnvLoaderUserConfig; + } +} From 667f3144c02fa0233c9a5046e93944466f373db6 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 15 Sep 2026 13:39:46 +0200 Subject: [PATCH 173/180] chore: add .env.example for machine-local secrets Document the secret keys expected in override env files for local and CI use. --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d432a26 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# place to write secrets, to be filled by CI pipeline or user's machine +ALCHEMY_API_KEY= +ETHERSCAN_API_KEY= +BLOCKSCOUT_API_KEY= +PRIVATE_KEY= +LIQUIDATOR_PRIVATE_KEY= From bf25da879dac7fd483960dc329cc2f0f7f901257 Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:38:09 -0400 Subject: [PATCH 174/180] ci: dispatch ABI publishes to hashpower-mcp so hosted MCP can follow Opens the same abi-published event the site already consumes, on the matching branch (dev or main). Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index 50c1599..100a9aa 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -168,6 +168,23 @@ jobs: -d "{\"event_type\":\"abi-published\",\"client_payload\":{\"package\":\"@hashpower/collateral-abi\",\"version\":\"${{ steps.bump.outputs.version }}\",\"source_ref\":\"${{ github.ref_name }}\"}}" echo "Dispatched abi-published to Lumerin-protocol/hashpower-io" + - name: Trigger hashpower-mcp ABI bump PR + if: steps.bump.outputs.level != 'none' + continue-on-error: true + env: + DISPATCH_TOKEN: ${{ secrets.HASHPOWER_MCP_DISPATCH_TOKEN || secrets.HASHPOWER_IO_DISPATCH_TOKEN }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::warning::No dispatch token for hashpower-mcp — add HASHPOWER_MCP_DISPATCH_TOKEN (or expand HASHPOWER_IO_DISPATCH_TOKEN to include that repo)." + exit 0 + fi + curl -sf -X POST \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/Lumerin-protocol/hashpower-mcp/dispatches \ + -d "{\"event_type\":\"abi-published\",\"client_payload\":{\"package\":\"@hashpower/collateral-abi\",\"version\":\"${{ steps.bump.outputs.version }}\",\"source_ref\":\"${{ github.ref_name }}\"}}" + echo "Dispatched abi-published to Lumerin-protocol/hashpower-mcp" + - name: Summary if: steps.bump.outputs.level != 'none' run: | From baa942320b5e74a8803543e84cc787162d9ea02d Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:16:24 -0400 Subject: [PATCH 175/180] ci: also publish collateral-abi from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-wire the npm publish workflow so a later dev→main promotion does not need a branch-filter edit. Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index 100a9aa..df5f75c 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -19,11 +19,11 @@ name: Publish @hashpower/collateral-abi on: push: branches: - # NOTE: publishing off `dev` while the repo operates there. - # At GA, change this to `main` (and update the `npm-publish` - # environment's deployment-branch rule to match) so npm only - # updates on mainline releases. + # `dev` = testnet npm bumps. `main` is already allowed so a + # dev→main promotion does not need a workflow edit. GitHub + # environment `npm-publish` must permit both branches. - dev + - main paths: - "contracts/abi/**" - "collateral-abi/**" From d1977e8480e033de60db0aa167a77f2a9d8aa7c8 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 17 Sep 2026 21:17:15 +0200 Subject: [PATCH 176/180] refactor: canonicalize the mainnet network name to base NETWORK meant three different things: the keeper required base-mainnet, the subgraph manifests and market-maker YAML used base, and the Hardhat config declared a network literally named base-mainnet. The GitHub environment that deploys mainnet already sets NETWORK=base, so the keeper and Hardhat were the outliers. Settle on base / base-sepolia / hardhat everywhere. The keeper still accepts base-mainnet as a deprecated alias, since task definitions registered before this change carry it, and keeps Alchemy's own base-mainnet spelling for the RPC subdomain. --- config/stg.env | 1 - contracts/.env.example | 2 +- contracts/hardhat.config.ts | 2 +- keeper/README.md | 2 +- keeper/scripts/debug-delivery-bootstrap.ts | 2 +- keeper/src/chain.ts | 2 +- keeper/src/config.ts | 22 +++++++++++++++++----- 7 files changed, 22 insertions(+), 11 deletions(-) delete mode 100644 config/stg.env diff --git a/config/stg.env b/config/stg.env deleted file mode 100644 index 6a8cdb0..0000000 --- a/config/stg.env +++ /dev/null @@ -1 +0,0 @@ -# we skipped stg environment to avoid maintaining two liquidity pools diff --git a/contracts/.env.example b/contracts/.env.example index 123e6d2..313d9c3 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -5,7 +5,7 @@ ALCHEMY_API_KEY= # ── Deployer ────────────────────────────────────────────────────────────── # Hex-encoded private key used as the deployer for `--network base-sepolia` and -# `--network base-mainnet`. Not required for `localhost` / `hardhat` networks. +# `--network base`. Not required for `localhost` / `hardhat` networks. PRIVATE_KEY= # ── Block explorer verification ─────────────────────────────────────────── diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 71069ac..9d6b839 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -73,7 +73,7 @@ export default defineConfig({ url: configVariable("ALCHEMY_API_KEY", "https://base-sepolia.g.alchemy.com/v2/{variable}"), accounts: [configVariable("PRIVATE_KEY")], }, - "base-mainnet": { + base: { type: "http", chainType: "l1", chainId: 8453, diff --git a/keeper/README.md b/keeper/README.md index 36fa5de..b40a99a 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -144,7 +144,7 @@ See `src/config.ts` for the authoritative shape. The minimum-viable set: | Var | Required | Purpose | | ------------------------------ | -------- | -------------------------------------- | -| `NETWORK` | yes | Chain label (e.g. `arbitrum-sepolia`) | +| `NETWORK` | yes | `hardhat`, `base-sepolia`, or `base` | | `ETH_NODE_ADDRESS` | yes | RPC URL | | `LIQUIDATOR_PRIVATE_KEY` | yes | Signer (single key for both venues) | | `VAULT_ADDRESS` | yes | Shared CollateralVault | diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index d03977f..2d14848 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -17,7 +17,7 @@ if (FUTURES === undefined || ALCHEMY === undefined) { } const RPC_URL = `https://${NETWORK}.g.alchemy.com/v2/${ALCHEMY}`; -const CHAINS = { "base-sepolia": baseSepolia, "base-mainnet": base, hardhat }; +const CHAINS = { "base-sepolia": baseSepolia, base, hardhat }; const chain = CHAINS[NETWORK as keyof typeof CHAINS]; const USERS: Address[] = ["0x1441Bc52156Cf18c12cde6A92aE6BDE8B7f775D4"]; diff --git a/keeper/src/chain.ts b/keeper/src/chain.ts index 6c8502f..8ac57ed 100644 --- a/keeper/src/chain.ts +++ b/keeper/src/chain.ts @@ -24,7 +24,7 @@ export interface Chain { const VIEM_CHAINS: Record = { hardhat, "base-sepolia": baseSepolia, - "base-mainnet": base, + base, }; /** diff --git a/keeper/src/config.ts b/keeper/src/config.ts index c3b214e..6b545d2 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -21,14 +21,24 @@ import type pino from "pino"; * - coordinator: cross-account ordering + concurrency * - runtime: healthcheck port, log level, dry-run, intervals */ -export type NetworkName = "hardhat" | "base-sepolia" | "base-mainnet"; +export type NetworkName = "hardhat" | "base-sepolia" | "base"; export const SUPPORTED_NETWORKS: readonly NetworkName[] = [ "hardhat", "base-sepolia", - "base-mainnet", + "base", ] as const; +/** + * Names accepted on input but normalized before use. `base-mainnet` was the + * keeper's own spelling before `NETWORK` was unified across the subgraph + * manifests and the market-maker; task definitions registered before that + * change still carry it. + */ +const NETWORK_ALIASES: Readonly> = { + "base-mainnet": "base", +}; + export interface Config { /** * Build identity stamp (image tag / git describe), surfaced on `/health` @@ -293,10 +303,11 @@ function parseAddressList(name: string): readonly Address[] { } function requireNetwork(): NetworkName { - const value = requireEnv("NETWORK"); + const raw = requireEnv("NETWORK"); + const value = NETWORK_ALIASES[raw] ?? raw; if (!(SUPPORTED_NETWORKS as readonly string[]).includes(value)) { throw new Error( - `NETWORK must be one of ${SUPPORTED_NETWORKS.join("|")}, got "${value}"`, + `NETWORK must be one of ${SUPPORTED_NETWORKS.join("|")}, got "${raw}"`, ); } return value as NetworkName; @@ -315,9 +326,10 @@ function resolveRpcUrl(network: NetworkName): string { return process.env.HARDHAT_RPC_URL ?? "http://127.0.0.1:8545"; } + // Alchemy keeps its own spelling for mainnet. const alchemySubdomain: Record, string> = { "base-sepolia": "base-sepolia", - "base-mainnet": "base-mainnet", + base: "base-mainnet", }; const apiKey = requireEnv("ALCHEMY_API_KEY"); return `https://${alchemySubdomain[network]}.g.alchemy.com/v2/${apiKey}`; From 7648f1f70fbb2f504d906665a8bd4dd529b543a4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 17 Sep 2026 21:17:57 +0200 Subject: [PATCH 177/180] feat: load public config from config/.env everywhere Contract addresses, start blocks and runtime knobs were duplicated across GitHub environment variables, the deploy workflows and ad-hoc loaders, so the same value could disagree between local runs, the subgraphs and ECS. Make config/dev.env and config/prd.env the single source for public values, and have every consumer read them: the keeper and market-maker via node --env-file, the subgraph renderers via ENV_FILE, and the deploy workflows by sourcing the file. Secrets stay in the repo-root .env locally and in GitHub Secrets in CI; both still win, because the real environment takes precedence over --env-file. The two ECS workflows built their environment block by naming each variable four times: once in the step env, once as a jq --arg, once in the jq object, and once more as a :- default. They now derive the block from the keys declared in the config file and name only secrets and computed values, so a public setting is written exactly once. Empty values are dropped so apps apply their own defaults, which were checked to match the defaults the workflows used to hardcode. Also drop the market-maker's custom dotenv loader in favour of --env-file, disable the stg deploy path, and remove a dead compose script in points-indexer. config/prd.env is still missing the mainnet addresses and start blocks; they exist nowhere in GitHub either, and are marked with TODOs. --- .github/infra/README.md | 6 + .github/workflows/deploy-col-mar-mm.yml | 119 ++++++------- .github/workflows/deploy-keeper.yml | 167 +++++++------------ .github/workflows/deploy-points-subgraph.yml | 45 ++--- .github/workflows/deploy-subgraph.yml | 46 ++--- .github/workflows/indexer-tests.yml | 3 +- .github/workflows/points-indexer-tests.yml | 3 +- config/dev.env | 56 +++++-- config/prd.env | 49 +++++- indexer/package.json | 6 +- keeper/package.json | 9 +- market-maker/package.json | 23 ++- market-maker/src/apps/futures/main.ts | 2 - market-maker/src/apps/perps/main.ts | 2 - market-maker/src/apps/portfolio/main.ts | 2 - market-maker/src/core/env.ts | 39 ----- points-indexer/package.json | 5 +- 17 files changed, 268 insertions(+), 314 deletions(-) delete mode 100644 market-maker/src/core/env.ts diff --git a/.github/infra/README.md b/.github/infra/README.md index 8b0b17c..1e81ea0 100644 --- a/.github/infra/README.md +++ b/.github/infra/README.md @@ -22,6 +22,12 @@ defs, separate wallets, separate logs. They only share the image so a single `docker push` rolls both venues forward (each can still be pinned to a different image tag). +The non-secret half of that `env:` block is not configured in Terraform +or in GitHub Variables. `deploy-col-mar-mm.yml` reads `config/dev.env` +or `config/prd.env` — the same files the market-maker loads locally — +and passes every key it finds to the task definition, alongside the +secrets it names explicitly. Add a public setting by editing that file. + ## Files * `ecs-task.tf` — reusable Terraform module template for one MM service. diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index 1e64961..b8698af 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -10,23 +10,23 @@ name: Deploy Collateral Margin Market Maker # # 1. Builds one Docker image from market-maker/ and pushes to GHCR # 2. Renders a new task-def revision for the portfolio app (MAKER_APP=portfolio) -# with image + env vars + secrets sourced from GitHub Variables and -# Secrets (no AWS Secrets Manager) +# with image + public config from config/.env + GitHub Secrets +# (no AWS Secrets Manager) # 3. Calls ecs:UpdateService to point the service at the new revision # and to scale it to the operator-chosen desired_count # +# Container config (contract addresses, log level, oracle URL, …) is NOT +# declared here. It comes from config/dev.env and config/prd.env, the same +# files the market-maker loads locally, and every key in the chosen file is +# passed to the task definition. To add or change one, edit that file. +# # GitHub Variables (configure per-environment under Settings -> Environments): -# MAKER_LOG_LEVEL default "info" -# MAKER_DRY_RUN default "false" -# MAKER_CANCEL_ORDERS_ON_SHUTDOWN default "true" -# MAKER_HEALTH_PORT default "3001" (must match TF cnt_port) -# MAKER_DESIRED_COUNT default "1" (set 0 to halt without redeploy) -# PERPS_ADDRESS HashPowerPerpsDEX contract address -# FUTURES_ADDRESS Futures contract address -# ETH_PRICE_FEED_ADDRESS optional Chainlink feed +# MAKER_DESIRED_COUNT default "1" (set 0 to halt without +# redeploy) — deploy orchestration, not +# container config # # GitHub Secrets (configure per-environment): -# AWS_ROLE_ARN_DEV / _STG / _LMN OIDC role ARNs from the TF output github_actions_role_arn +# AWS_ROLE_ARN_DEV / _LMN OIDC role ARNs from the TF output github_actions_role_arn # ALCHEMY_API_KEY shared Alchemy project key (composes the RPC URL) # FUTURES_MM_PRIVATE_KEY shared portfolio signer private key (both venues) # SLACK_WEBHOOK_URL (org or repo level) for slack-notify @@ -35,21 +35,20 @@ on: push: branches: - dev - - stg - main - "cicd/**" paths: - "market-maker/**" + - "config/*.env" - ".github/workflows/deploy-col-mar-mm.yml" workflow_dispatch: inputs: environment: - description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + description: "Target environment (dev=DEV, main=LMN/PROD)" required: true type: choice options: - dev - - stg - main permissions: @@ -105,25 +104,23 @@ jobs: # URL_HOST_PREFIX matches the per-env Route53 alias produced by the # collateral-margin TF stack (futures_mm_endpoint in 00_outputs.tf): - # dev/stg get a subdomain, prod uses the apex. + # dev gets a subdomain, prod uses the apex. + # + # MAKER_ENV also selects both the YAML profile baked into the image + # and the config/.env read at deploy time. case "$ENV" in dev) SUFFIX="dev" MAKER_ENV="dev" URL_HOST_PREFIX="dev." ;; - stg) - SUFFIX="stg" - MAKER_ENV="stg" - URL_HOST_PREFIX="stg." - ;; main) SUFFIX="lmn" MAKER_ENV="prd" URL_HOST_PREFIX="" ;; *) - echo "::error::Unknown environment '$ENV'" + echo "::error::Unknown environment '$ENV' (expected dev or main)" exit 1 ;; esac @@ -192,12 +189,17 @@ jobs: environment: ${{ needs.build.outputs.environment }} steps: + # Needed for config/.env, which supplies the container environment. + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && - secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' - && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + secrets.AWS_ROLE_ARN_DEV || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarMM-portfolio-${{ github.run_id }} @@ -234,18 +236,8 @@ jobs: REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} - # Per-environment GitHub Variables (non-secret personality) - MAKER_LOG_LEVEL: ${{ vars.MAKER_LOG_LEVEL }} - MAKER_DRY_RUN: ${{ vars.MAKER_DRY_RUN }} - MAKER_CANCEL_ORDERS_ON_SHUTDOWN: ${{ vars.MAKER_CANCEL_ORDERS_ON_SHUTDOWN }} - MAKER_HEALTH_PORT: ${{ vars.MAKER_HEALTH_PORT }} + # Deploy orchestration (not container config) MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} - ETH_PRICE_FEED_ADDRESS: ${{ vars.ETH_PRICE_FEED_ADDRESS }} - HASHPRICE_ORACLE_SUBGRAPH_URL: ${{ vars.HASHPRICE_ORACLE_SUBGRAPH_URL }} - - # Both venue addresses — the portfolio app quotes perps + futures. - PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} - FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} # Per-environment GitHub Secrets (encrypted personality). Secrets # only flow into this step's process env; they are never written to @@ -260,6 +252,7 @@ jobs: COMMIT_HASH: ${{ github.sha }} run: | set -euo pipefail + CONFIG="config/${MAKER_ENV}.env" DESIRED_COUNT="${MAKER_DESIRED_COUNT:-1}" echo "🚀 Deploying portfolio to ${{ needs.build.outputs.environment }}" @@ -268,6 +261,7 @@ jobs: echo " Task Family: ${TASK_FAMILY}" echo " Image: ${IMAGE}" echo " MAKER_ENV: ${MAKER_ENV}" + echo " Config: ${CONFIG}" echo " Desired Count: ${DESIRED_COUNT}" # Fetch current task def, strip non-registerable metadata, swap image @@ -286,36 +280,30 @@ jobs: # Build the full env block from scratch. Personality lives ONLY here; # the Terraform stub deliberately ships zero env vars. - echo "🔧 Injecting environment from GitHub Variables / Secrets..." - jq -n \ - --arg MAKER_APP "${MAKER_APP}" \ - --arg MAKER_ENV "${MAKER_ENV}" \ - --arg MAKER_HEALTH_PORT "${MAKER_HEALTH_PORT:-3001}" \ - --arg MAKER_LOG_LEVEL "${MAKER_LOG_LEVEL:-info}" \ - --arg MAKER_DRY_RUN "${MAKER_DRY_RUN:-false}" \ - --arg MAKER_CANCEL_ORDERS_ON_SHUTDOWN "${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true}" \ - --arg COMMIT_HASH "${COMMIT_HASH}" \ - --arg ETH_PRICE_FEED_ADDRESS "${ETH_PRICE_FEED_ADDRESS:-}" \ - --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ - --arg PRIVATE_KEY "${PRIVATE_KEY}" \ - --arg PERPS_ADDRESS "${PERPS_ADDRESS}" \ - --arg FUTURES_ADDRESS "${FUTURES_ADDRESS}" \ - --arg HASHPRICE_ORACLE_SUBGRAPH_URL "${HASHPRICE_ORACLE_SUBGRAPH_URL}" \ - '[ - {name:"MAKER_APP", value:$MAKER_APP}, - {name:"MAKER_ENV", value:$MAKER_ENV}, - {name:"MAKER_HEALTH_PORT", value:$MAKER_HEALTH_PORT}, - {name:"MAKER_LOG_LEVEL", value:$MAKER_LOG_LEVEL}, - {name:"MAKER_DRY_RUN", value:$MAKER_DRY_RUN}, - {name:"MAKER_CANCEL_ORDERS_ON_SHUTDOWN", value:$MAKER_CANCEL_ORDERS_ON_SHUTDOWN}, - {name:"COMMIT_HASH", value:$COMMIT_HASH}, - {name:"ETH_PRICE_FEED_ADDRESS", value:$ETH_PRICE_FEED_ADDRESS}, - {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, - {name:"PRIVATE_KEY", value:$PRIVATE_KEY}, - {name:"PERPS_ADDRESS", value:$PERPS_ADDRESS}, - {name:"FUTURES_ADDRESS", value:$FUTURES_ADDRESS}, - {name:"HASHPRICE_ORACLE_SUBGRAPH_URL", value:$HASHPRICE_ORACLE_SUBGRAPH_URL} - ]' > env-block.json + # + # The block is every key declared in config/.env plus the + # secrets and computed values from the step env above. Sourcing + # cannot clobber a secret, because no secret is named in the config + # file. jq then reads values straight out of the environment, so each + # key is written exactly once: in the config file. + echo "🔧 Injecting environment from ${CONFIG} + GitHub Secrets..." + set -a && . "$CONFIG" && set +a + + KEYS=$( { sed -n 's/^[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CONFIG"; \ + printf '%s\n' MAKER_APP MAKER_ENV COMMIT_HASH ALCHEMY_API_KEY PRIVATE_KEY; } ) + + # Empty values are dropped so the maker applies its own defaults + # instead of parsing an empty string. + jq -n --arg keys "$KEYS" '[ + $keys + | split("\n") + | unique + | .[] + | select(. != "" and ($ENV[.] // "") != "") + | {name: ., value: $ENV[.]} + ]' > env-block.json + + echo " Container env keys: $(jq -r '[.[].name] | join(", ")' env-block.json)" # Replace the entire env block; also drop any stale `secrets` block # (we no longer use Secrets Manager). @@ -356,8 +344,7 @@ jobs: uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ needs.build.outputs.environment == 'dev' && - secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' - && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + secrets.AWS_ROLE_ARN_DEV || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarMM-portfolio-Verify-${{ github.run_id }} diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 19ea242..a95311a 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -6,57 +6,44 @@ name: Deploy Collateral Margin Keeper # Terraform (.bedrock/.terragrunt/06_col_mar_keeper_svc.tf) builds ECS shell, # internal ALB, Route53 keeper.{env}.hashpower.exchange, and log group. # This workflow builds the image, pushes to GHCR, and registers task-def -# revisions with full runtime config from GitHub. +# revisions with the runtime config from config/.env plus secrets. # -# GitHub Variables (per environment: dev / stg / main): -# NETWORK e.g. base-sepolia -# VAULT_ADDRESS CollateralVault proxy -# PME_ADDRESS PortfolioMarginEngine proxy -# PERPS_ADDRESS HashPowerPerpsDEX -# FUTURES_ADDRESS Futures proxy -# HASHPRICE_USD_ADDRESS HashpriceUSD aggregator -# BTC_USD_FEED_ADDRESS Chainlink BTC/USDC feed -# ETH_USD_FEED_ADDRESS optional — gas cost logging -# DRY_RUN "true" | "false" (default false) -# KEEPER_MIN_PROFIT_MARGIN default "0" -# KEEPER_DESIRED_COUNT default "1" (set "0" to halt) -# LOG_LEVEL default "info" -# DISCOVERY_MODE events | webhook | both (default events) -# BACKFILL_FROM_BLOCK optional startup discovery backfill anchor -# DELIVERY_KEEPER_ENABLED default "false" -# HEALTH_PORT default "3000" (must match TF cnt_port) -# PRICE_MOVE_TRIGGER_BPS default "1" -# SWEEP_INTERVAL_MS default "60000" -# ALERT_WEBHOOK_URL optional Slack/Discord webhook +# Container config (NETWORK, contract addresses, log level, intervals, …) is +# NOT declared here. It comes from config/dev.env and config/prd.env, which are +# the same files the keeper loads locally, and every key in the chosen file is +# passed to the task definition. To add or change one, edit that file. +# +# GitHub Variables (per environment: dev / main): +# KEEPER_DESIRED_COUNT default "1" (set "0" to halt) — deploy +# orchestration, not container config # # GitHub Secrets (per environment): -# ALCHEMY_API_KEY RPC (or set ETH_NODE_ADDRESS via var — not secret) -# LIQUIDATOR_PRIVATE_KEY same wallet as legacy perps keeper -# WEBHOOK_SECRET optional — Goldsky bearer token +# ALCHEMY_API_KEY RPC (or set ETH_NODE_ADDRESS in config) +# LIQUIDATOR_PRIVATE_KEY same wallet as legacy perps keeper +# WEBHOOK_SECRET optional — Goldsky bearer token # # Repository secrets (all environments): -# AWS_ROLE_ARN_DEV / _STG / _LMN from terragrunt output github_actions_role_arn +# AWS_ROLE_ARN_DEV / _LMN from terragrunt output github_actions_role_arn # SLACK_WEBHOOK_URL optional — deploy notifications on: push: branches: - dev - - stg - main - "cicd/**" paths: - "keeper/**" + - "config/*.env" - ".github/workflows/deploy-keeper.yml" workflow_dispatch: inputs: environment: - description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + description: "Target environment (dev=DEV, main=LMN/PROD)" required: true type: choice options: - dev - - stg - main permissions: @@ -77,6 +64,7 @@ jobs: tag: ${{ steps.gen_tag.outputs.tag_name }} environment: ${{ steps.gen_tag.outputs.environment }} env_suffix: ${{ steps.env_config.outputs.env_suffix }} + config_env: ${{ steps.env_config.outputs.config_env }} aws_region: ${{ steps.env_config.outputs.aws_region }} ecs_cluster: ${{ steps.env_config.outputs.ecs_cluster }} ecs_service: ${{ steps.env_config.outputs.ecs_service }} @@ -106,26 +94,27 @@ jobs: echo "aws_region=us-east-1" >> $GITHUB_OUTPUT echo "task_family=tsk-col-mar-keeper" >> $GITHUB_OUTPUT + # CONFIG_ENV names the config/.env holding this environment's + # public container config; SUFFIX names the AWS resources. case "$ENV" in dev) SUFFIX="dev" URL_HOST_PREFIX="dev." - ;; - stg) - SUFFIX="stg" - URL_HOST_PREFIX="stg." + CONFIG_ENV="dev" ;; main) SUFFIX="lmn" URL_HOST_PREFIX="" + CONFIG_ENV="prd" ;; *) - echo "::error::Unknown environment '$ENV'" + echo "::error::Unknown environment '$ENV' (expected dev or main)" exit 1 ;; esac echo "env_suffix=${SUFFIX}" >> $GITHUB_OUTPUT + echo "config_env=${CONFIG_ENV}" >> $GITHUB_OUTPUT echo "ecs_cluster=ecs-derivatives-marketplace-${SUFFIX}" >> $GITHUB_OUTPUT echo "ecs_service=svc-col-mar-keeper-${SUFFIX}" >> $GITHUB_OUTPUT echo "keeper_health_url=https://keeper.${URL_HOST_PREFIX}hashpower.exchange/health" >> $GITHUB_OUTPUT @@ -188,10 +177,16 @@ jobs: skipped: ${{ steps.svc_check.outputs.skip }} steps: + # Needed for config/.env, which supplies the container environment. + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarKeeper-${{ github.run_id }} @@ -227,34 +222,18 @@ jobs: TASK_FAMILY: ${{ needs.build.outputs.task_family }} REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} - KEEPER_VERSION: ${{ needs.build.outputs.version }} + CONFIG_ENV: ${{ needs.build.outputs.config_env }} - NETWORK: ${{ vars.NETWORK }} - VAULT_ADDRESS: ${{ vars.VAULT_ADDRESS }} - PME_ADDRESS: ${{ vars.PME_ADDRESS }} - PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} - FUTURES_ADDRESS: ${{ vars.FUTURES_ADDRESS }} - HASHPRICE_USD_ADDRESS: ${{ vars.HASHPRICE_USD_ADDRESS }} - BTC_USD_FEED_ADDRESS: ${{ vars.BTC_USD_FEED_ADDRESS }} - ETH_USD_FEED_ADDRESS: ${{ vars.ETH_USD_FEED_ADDRESS }} - DRY_RUN: ${{ vars.DRY_RUN }} - KEEPER_MIN_PROFIT_MARGIN: ${{ vars.KEEPER_MIN_PROFIT_MARGIN }} + # Everything public lives in config/.env. Only secrets and + # values computed by this run are declared here. + KEEPER_VERSION: ${{ needs.build.outputs.version }} KEEPER_DESIRED_COUNT: ${{ vars.KEEPER_DESIRED_COUNT }} - LOG_LEVEL: ${{ vars.LOG_LEVEL }} - DISCOVERY_MODE: ${{ vars.DISCOVERY_MODE }} - BACKFILL_FROM_BLOCK: ${{ vars.BACKFILL_FROM_BLOCK }} - DELIVERY_KEEPER_ENABLED: ${{ vars.DELIVERY_KEEPER_ENABLED }} - HEALTH_PORT: ${{ vars.HEALTH_PORT }} - PRICE_MOVE_TRIGGER_BPS: ${{ vars.PRICE_MOVE_TRIGGER_BPS }} - SWEEP_INTERVAL_MS: ${{ vars.SWEEP_INTERVAL_MS }} - ALERT_WEBHOOK_URL: ${{ vars.ALERT_WEBHOOK_URL }} - ETH_NODE_ADDRESS: ${{ vars.ETH_NODE_ADDRESS }} - ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} LIQUIDATOR_PRIVATE_KEY: ${{ secrets.LIQUIDATOR_PRIVATE_KEY }} WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | set -euo pipefail + CONFIG="config/${CONFIG_ENV}.env" DESIRED_COUNT="${KEEPER_DESIRED_COUNT:-1}" echo "🚀 Deploying keeper to ${{ needs.build.outputs.environment }}" @@ -262,7 +241,7 @@ jobs: echo " Service: ${SERVICE}" echo " Task Family: ${TASK_FAMILY}" echo " Image: ${IMAGE}" - echo " DRY_RUN: ${DRY_RUN:-false}" + echo " Config: ${CONFIG}" echo " Desired Count: ${DESIRED_COUNT}" aws ecs describe-task-definition \ @@ -277,58 +256,28 @@ jobs: .compatibilities, .registeredAt, .registeredBy) ' task-def.json > new-task-def.json - jq -n \ - --arg NETWORK "${NETWORK}" \ - --arg VAULT_ADDRESS "${VAULT_ADDRESS}" \ - --arg PME_ADDRESS "${PME_ADDRESS}" \ - --arg PERPS_ADDRESS "${PERPS_ADDRESS}" \ - --arg FUTURES_ADDRESS "${FUTURES_ADDRESS}" \ - --arg HASHPRICE_USD_ADDRESS "${HASHPRICE_USD_ADDRESS}" \ - --arg BTC_USD_FEED_ADDRESS "${BTC_USD_FEED_ADDRESS}" \ - --arg ETH_USD_FEED_ADDRESS "${ETH_USD_FEED_ADDRESS:-}" \ - --arg DRY_RUN "${DRY_RUN:-false}" \ - --arg KEEPER_MIN_PROFIT_MARGIN "${KEEPER_MIN_PROFIT_MARGIN:-0}" \ - --arg LOG_LEVEL "${LOG_LEVEL:-info}" \ - --arg DISCOVERY_MODE "${DISCOVERY_MODE:-events}" \ - --arg BACKFILL_FROM_BLOCK "${BACKFILL_FROM_BLOCK:-}" \ - --arg DELIVERY_KEEPER_ENABLED "${DELIVERY_KEEPER_ENABLED:-false}" \ - --arg HEALTH_PORT "${HEALTH_PORT:-3000}" \ - --arg PRICE_MOVE_TRIGGER_BPS "${PRICE_MOVE_TRIGGER_BPS:-1}" \ - --arg SWEEP_INTERVAL_MS "${SWEEP_INTERVAL_MS:-60000}" \ - --arg ALERT_WEBHOOK_URL "${ALERT_WEBHOOK_URL:-}" \ - --arg ETH_NODE_ADDRESS "${ETH_NODE_ADDRESS:-}" \ - --arg ALCHEMY_API_KEY "${ALCHEMY_API_KEY}" \ - --arg LIQUIDATOR_PRIVATE_KEY "${LIQUIDATOR_PRIVATE_KEY}" \ - --arg WEBHOOK_SECRET "${WEBHOOK_SECRET:-}" \ - --arg KEEPER_VERSION "${KEEPER_VERSION}" \ - '[ - {name:"KEEPER_VERSION", value:$KEEPER_VERSION}, - {name:"NETWORK", value:$NETWORK}, - {name:"VAULT_ADDRESS", value:$VAULT_ADDRESS}, - {name:"PME_ADDRESS", value:$PME_ADDRESS}, - {name:"PERPS_ADDRESS", value:$PERPS_ADDRESS}, - {name:"FUTURES_ADDRESS", value:$FUTURES_ADDRESS}, - {name:"HASHPRICE_USD_ADDRESS", value:$HASHPRICE_USD_ADDRESS}, - {name:"BTC_USD_FEED_ADDRESS", value:$BTC_USD_FEED_ADDRESS}, - {name:"ETH_USD_FEED_ADDRESS", value:$ETH_USD_FEED_ADDRESS}, - {name:"DRY_RUN", value:$DRY_RUN}, - {name:"KEEPER_MIN_PROFIT_MARGIN", value:$KEEPER_MIN_PROFIT_MARGIN}, - {name:"LOG_LEVEL", value:$LOG_LEVEL}, - {name:"DISCOVERY_MODE", value:$DISCOVERY_MODE}, - {name:"DELIVERY_KEEPER_ENABLED", value:$DELIVERY_KEEPER_ENABLED}, - {name:"HEALTH_PORT", value:$HEALTH_PORT}, - {name:"PRICE_MOVE_TRIGGER_BPS", value:$PRICE_MOVE_TRIGGER_BPS}, - {name:"SWEEP_INTERVAL_MS", value:$SWEEP_INTERVAL_MS}, - {name:"ALERT_WEBHOOK_URL", value:$ALERT_WEBHOOK_URL}, - {name:"ETH_NODE_ADDRESS", value:$ETH_NODE_ADDRESS}, - {name:"ALCHEMY_API_KEY", value:$ALCHEMY_API_KEY}, - {name:"LIQUIDATOR_PRIVATE_KEY", value:$LIQUIDATOR_PRIVATE_KEY}, - {name:"WEBHOOK_SECRET", value:$WEBHOOK_SECRET} - ] + ( - if $BACKFILL_FROM_BLOCK == "" then [] - else [{name:"BACKFILL_FROM_BLOCK", value:$BACKFILL_FROM_BLOCK}] - end - )' > env-block.json + # The container environment is every key declared in config/.env + # plus the secrets and computed values from the step env above. + # Sourcing cannot clobber a secret, because no secret is named in the + # config file. jq then reads values straight out of the environment, + # so each key is written exactly once: in the config file. + set -a && . "$CONFIG" && set +a + + KEYS=$( { sed -n 's/^[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CONFIG"; \ + printf '%s\n' KEEPER_VERSION ALCHEMY_API_KEY LIQUIDATOR_PRIVATE_KEY WEBHOOK_SECRET; } ) + + # Empty values are dropped so the keeper applies its own defaults + # instead of parsing an empty string. + jq -n --arg keys "$KEYS" '[ + $keys + | split("\n") + | unique + | .[] + | select(. != "" and ($ENV[.] // "") != "") + | {name: ., value: $ENV[.]} + ]' > env-block.json + + echo " Container env keys: $(jq -r '[.[].name] | join(", ")' env-block.json)" jq --slurpfile env env-block.json ' .containerDefinitions[0].environment = $env[0] | @@ -368,7 +317,7 @@ jobs: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || needs.build.outputs.environment == 'stg' && secrets.AWS_ROLE_ARN_STG || secrets.AWS_ROLE_ARN_LMN }} + role-to-assume: ${{ needs.build.outputs.environment == 'dev' && secrets.AWS_ROLE_ARN_DEV || secrets.AWS_ROLE_ARN_LMN }} aws-region: ${{ needs.build.outputs.aws_region }} role-session-name: GitHubActions-ColMarKeeper-Verify-${{ github.run_id }} diff --git a/.github/workflows/deploy-points-subgraph.yml b/.github/workflows/deploy-points-subgraph.yml index f95dd8d..6c5d9a1 100644 --- a/.github/workflows/deploy-points-subgraph.yml +++ b/.github/workflows/deploy-points-subgraph.yml @@ -4,33 +4,32 @@ on: push: branches: - dev - - stg - main - "cicd/**" paths: - "points-indexer/**" + - "config/*.env" - "contracts/abi/Points.json" - "contracts/abi/PointsRedeemer.json" - ".github/workflows/deploy-points-subgraph.yml" pull_request: branches: - dev - - stg - main paths: - "points-indexer/**" + - "config/*.env" - "contracts/abi/Points.json" - "contracts/abi/PointsRedeemer.json" - ".github/workflows/deploy-points-subgraph.yml" workflow_dispatch: inputs: environment: - description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + description: "Target environment (dev=DEV, main=LMN/PROD)" required: true type: choice options: - dev - - stg - main concurrency: @@ -116,19 +115,21 @@ jobs: fi echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT - # Rolling tag and public endpoint per environment + # Rolling tag, public endpoint, and the config/.env to load. case $ENV in dev) echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT echo "goldsky_endpoint=${{ vars.DEV_GS_POINTS }}" >> $GITHUB_OUTPUT - ;; - stg) - echo "goldsky_rolling_tag=stg-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.STG_GS_POINTS }}" >> $GITHUB_OUTPUT + echo "config_env=dev" >> $GITHUB_OUTPUT ;; main) echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT echo "goldsky_endpoint=${{ vars.LMN_GS_POINTS }}" >> $GITHUB_OUTPUT + echo "config_env=prd" >> $GITHUB_OUTPUT + ;; + *) + echo "::error::Unknown environment '$ENV' (expected dev or main)" + exit 1 ;; esac @@ -138,24 +139,24 @@ jobs: working-directory: ./points-indexer run: pnpm install --frozen-lockfile + # Addresses, start blocks and NETWORK all come from config/.env. + # PR builds render from the dummy values in .env.example, since real + # addresses are only needed at deploy time. - name: Prepare subgraph configuration working-directory: ./points-indexer env: - NETWORK: ${{ vars.NETWORK }} - POINTS_ADDRESS: ${{ vars.POINTS_ADDRESS }} - POINTS_START_BLOCK: ${{ vars.POINTS_START_BLOCK }} - REDEEMER_ADDRESS: ${{ vars.REDEEMER_ADDRESS }} - REDEEMER_START_BLOCK: ${{ vars.REDEEMER_START_BLOCK }} + CONFIG_ENV: ${{ steps.env.outputs.config_env }} + DEPLOY_ENV: ${{ needs.setup.outputs.environment }} run: | + set -euo pipefail if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then echo "ℹ️ PR build — using .env.example dummy values" - set -a && source .env.example && set +a + ENV_FILE=.env.example + else + ENV_FILE="../config/${CONFIG_ENV}.env" fi - echo "⚙️ Preparing subgraph for ${{ needs.setup.outputs.environment }}..." - echo " Network: ${NETWORK}" - echo " Points: ${POINTS_ADDRESS} (block ${POINTS_START_BLOCK})" - echo " Redeemer: ${REDEEMER_ADDRESS} (block ${REDEEMER_START_BLOCK})" - pnpm prepare:env + echo "⚙️ Preparing subgraph for ${DEPLOY_ENV} from ${ENV_FILE}..." + ENV_FILE="$ENV_FILE" pnpm prepare:env echo "✅ Configuration ready" echo "--- subgraph.yaml ---" cat subgraph.yaml @@ -220,7 +221,7 @@ jobs: id: goldsky_deploy working-directory: ./points-indexer env: - GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || needs.build.outputs.environment == 'stg' && secrets.STG_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} + GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} GOLDSKY_SUBGRAPH_NAME: ${{ needs.build.outputs.goldsky_subgraph_name }} SUBGRAPH_VERSION: ${{ needs.build.outputs.version }} GOLDSKY_ROLLING_TAG: ${{ needs.build.outputs.goldsky_rolling_tag }} @@ -347,7 +348,7 @@ jobs: if [ -z "${GOLDSKY_ENDPOINT}" ]; then echo "⚠️ No Goldsky endpoint URL configured — skipping verification" - echo " Set DEV_GS_POINTS / STG_GS_POINTS / LMN_GS_POINTS org variable" + echo " Set DEV_GS_POINTS / LMN_GS_POINTS org variable" exit 0 fi diff --git a/.github/workflows/deploy-subgraph.yml b/.github/workflows/deploy-subgraph.yml index f507403..eb219a2 100644 --- a/.github/workflows/deploy-subgraph.yml +++ b/.github/workflows/deploy-subgraph.yml @@ -4,31 +4,30 @@ on: push: branches: - dev - - stg - main - "cicd/**" paths: - "indexer/**" + - "config/*.env" - "contracts/abi/CollateralVault.json" - ".github/workflows/deploy-subgraph.yml" pull_request: branches: - dev - - stg - main paths: - "indexer/**" + - "config/*.env" - "contracts/abi/CollateralVault.json" - ".github/workflows/deploy-subgraph.yml" workflow_dispatch: inputs: environment: - description: "Target environment (dev=DEV, stg=STG, main=LMN/PROD)" + description: "Target environment (dev=DEV, main=LMN/PROD)" required: true type: choice options: - dev - - stg - main concurrency: @@ -114,19 +113,21 @@ jobs: fi echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT - # Rolling tag and public endpoint per environment + # Rolling tag, public endpoint, and the config/.env to load. case $ENV in dev) echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT echo "goldsky_endpoint=${{ vars.DEV_GS_VAULT }}" >> $GITHUB_OUTPUT - ;; - stg) - echo "goldsky_rolling_tag=stg-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.STG_GS_VAULT }}" >> $GITHUB_OUTPUT + echo "config_env=dev" >> $GITHUB_OUTPUT ;; main) echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT echo "goldsky_endpoint=${{ vars.LMN_GS_VAULT }}" >> $GITHUB_OUTPUT + echo "config_env=prd" >> $GITHUB_OUTPUT + ;; + *) + echo "::error::Unknown environment '$ENV' (expected dev or main)" + exit 1 ;; esac @@ -136,25 +137,24 @@ jobs: working-directory: ./indexer run: pnpm install --frozen-lockfile + # Addresses, start blocks and NETWORK all come from config/.env. + # PR builds render from the dummy values in .env.example, since real + # addresses are only needed at deploy time. - name: Prepare subgraph configuration working-directory: ./indexer env: - NETWORK: ${{ vars.NETWORK }} - VAULT_ADDRESS: ${{ vars.VAULT_ADDRESS }} - VAULT_START_BLOCK: ${{ vars.VAULT_START_BLOCK }} - PERPS_ADDRESS: ${{ vars.PERPS_ADDRESS }} - OPTIONS_ADDRESS: ${{ vars.OPTIONS_ADDRESS }} + CONFIG_ENV: ${{ steps.env.outputs.config_env }} + DEPLOY_ENV: ${{ needs.setup.outputs.environment }} run: | + set -euo pipefail if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then echo "ℹ️ PR build — using .env.example dummy values" - set -a && source .env.example && set +a + ENV_FILE=.env.example + else + ENV_FILE="../config/${CONFIG_ENV}.env" fi - echo "⚙️ Preparing subgraph for ${{ needs.setup.outputs.environment }}..." - echo " Network: ${NETWORK}" - echo " Vault: ${VAULT_ADDRESS} (block ${VAULT_START_BLOCK})" - echo " Perps: ${PERPS_ADDRESS}" - echo " Options: ${OPTIONS_ADDRESS}" - pnpm prepare:env + echo "⚙️ Preparing subgraph for ${DEPLOY_ENV} from ${ENV_FILE}..." + ENV_FILE="$ENV_FILE" pnpm prepare:env echo "✅ Configuration ready" echo "--- subgraph.yaml ---" cat subgraph.yaml @@ -219,7 +219,7 @@ jobs: id: goldsky_deploy working-directory: ./indexer env: - GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || needs.build.outputs.environment == 'stg' && secrets.STG_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} + GOLDSKY_API_KEY: ${{ needs.build.outputs.environment == 'dev' && secrets.DEV_GOLDSKY_API_KEY || secrets.LMN_GOLDSKY_API_KEY }} GOLDSKY_SUBGRAPH_NAME: ${{ needs.build.outputs.goldsky_subgraph_name }} SUBGRAPH_VERSION: ${{ needs.build.outputs.version }} GOLDSKY_ROLLING_TAG: ${{ needs.build.outputs.goldsky_rolling_tag }} @@ -346,7 +346,7 @@ jobs: if [ -z "${GOLDSKY_ENDPOINT}" ]; then echo "⚠️ No Goldsky endpoint URL configured — skipping verification" - echo " Set DEV_GS_VAULT / STG_GS_VAULT / LMN_GS_VAULT org variable" + echo " Set DEV_GS_VAULT / LMN_GS_VAULT org variable" exit 0 fi diff --git a/.github/workflows/indexer-tests.yml b/.github/workflows/indexer-tests.yml index eef8d5d..80ef104 100644 --- a/.github/workflows/indexer-tests.yml +++ b/.github/workflows/indexer-tests.yml @@ -64,8 +64,7 @@ jobs: # are only needed at deploy time. - name: Prepare subgraph (with .env.example defaults) run: | - set -a && source .env.example && set +a - pnpm prepare:env + ENV_FILE=.env.example pnpm prepare:env echo "--- subgraph.yaml ---" cat subgraph.yaml diff --git a/.github/workflows/points-indexer-tests.yml b/.github/workflows/points-indexer-tests.yml index d71c51a..ae0e4ce 100644 --- a/.github/workflows/points-indexer-tests.yml +++ b/.github/workflows/points-indexer-tests.yml @@ -76,8 +76,7 @@ jobs: - name: Prepare subgraph (with .env.example defaults) working-directory: ./points-indexer run: | - set -a && source .env.example && set +a - pnpm prepare:env + ENV_FILE=.env.example pnpm prepare:env echo "--- subgraph.yaml ---" cat subgraph.yaml diff --git a/config/dev.env b/config/dev.env index 6c99eb4..57cb539 100644 --- a/config/dev.env +++ b/config/dev.env @@ -1,24 +1,50 @@ -BTC_USD_FEED_ADDRESS="0x37b5e07c59238ad3bb11ac27129387a67f3340b6" -COLLATERAL_TOKEN_ADDRESS="0xdd15eed84065a58c9e9ff9e95fb996be0fff22aa" -FUTURES_ADDRESS="0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479" -HASHPRICE_USD_ADDRESS="0x865c4fb61b85cda3d39a94d4e8de6962f7626c4d" -HOOK_ADDRESS="0x99c28ff216a80e1a14ff276775ec458150979959" -PERPS_ADDRESS="0x0d412BC34a48e434144687Aac03b9C593F5237B6" -PME_ADDRESS="0x3899e429ef47140ec46c6e23f04253c24f221b69" -POINTS_ADDRESS="0x153F6cb4386d717AD94791E6Ee8ae37f80315972" -VAULT_ADDRESS="0x54a79e2a5c60ace37b280ebbcda51b4e903d25f0" -# OPTIONS_ADDRESS="" +# Public, per-environment values for DEV (base-sepolia). +# +# Loaded by Hardhat (`--env dev`), the market-maker and keeper (`--env-file`), +# the subgraph renderers (`ENV_FILE`), and the deploy workflows, which build the +# ECS environment block from the keys in this file. +# +# Never put secrets here. Those live in the repo-root `.env` locally and in +# GitHub Secrets in CI, and both take precedence over this file. +# +# Values are unquoted on purpose: `docker run --env-file` does not strip quotes +# in every CLI version, unlike Node, bash, and Compose. + +NETWORK=base-sepolia + +# ── Contracts ────────────────────────────────────────────────────────────── +BTC_USD_FEED_ADDRESS=0x37b5E07C59238ad3bB11AC27129387A67F3340B6 +COLLATERAL_TOKEN_ADDRESS=0xdd15eed84065a58c9e9ff9e95fb996be0fff22aa +FUTURES_ADDRESS=0x56d8d4a03a0f34b93B86E0b7941aFF29178D0479 +HASHPRICE_USD_ADDRESS=0x865c4fB61B85CDA3D39A94D4e8DE6962f7626C4D +HOOK_ADDRESS=0x99c28ff216a80e1a14ff276775ec458150979959 +PERPS_ADDRESS=0x0d412BC34a48e434144687Aac03b9C593F5237B6 +PME_ADDRESS=0x3899e429Ef47140eC46c6E23F04253C24F221b69 +POINTS_ADDRESS=0x153F6cb4386d717AD94791E6Ee8ae37f80315972 +VAULT_ADDRESS=0x54A79e2a5C60ACe37b280eBbCda51b4E903d25F0 SAFE_OWNER_ADDRESS= +# OPTIONS_ADDRESS is unset: no options engine on base-sepolia yet. The vault +# subgraph buckets an internal transfer as OTHER when it does not match. +# ── Subgraph manifests ───────────────────────────────────────────────────── +POINTS_START_BLOCK=42622435 +# TODO: VAULT_START_BLOCK has never been set in the GitHub dev environment, so +# the vault subgraph has been rendering an empty startBlock. Set it to the +# CollateralVault deployment block. + +# ── PME stress shocks (WAD-scaled) ───────────────────────────────────────── IM_SPOT_SHOCK=100000000000000000 MM_SPOT_SHOCK=100000000000000000 IM_VOL_SHOCK=50000000000000000 MM_VOL_SHOCK=50000000000000000 -NETWORK=base-sepolia -HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-oracles/dev-latest/gn -BACKFILL_FROM_BLOCK=40113440 +# ── Keeper runtime ───────────────────────────────────────────────────────── +BACKFILL_FROM_BLOCK=45600575 DELIVERY_KEEPER_ENABLED=true +HEALTH_PORT=3000 +KEEPER_MIN_PROFIT_MARGIN=0 +LOG_LEVEL=info - - +# ── Market-maker runtime ─────────────────────────────────────────────────── +MAKER_LOG_LEVEL=debug +HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-oracles/dev-latest/gn diff --git a/config/prd.env b/config/prd.env index 40d5339..6c4072d 100644 --- a/config/prd.env +++ b/config/prd.env @@ -1,13 +1,48 @@ -NETWORK=base-mainnet +# Public, per-environment values for PRD (base mainnet). +# +# Loaded by Hardhat (`--env prd`), the market-maker and keeper (`--env-file`), +# the subgraph renderers (`ENV_FILE`), and the deploy workflows, which build the +# ECS environment block from the keys in this file. +# +# Never put secrets here. Those live in the repo-root `.env` locally and in +# GitHub Secrets in CI, and both take precedence over this file. +# +# Values are unquoted on purpose: `docker run --env-file` does not strip quotes +# in every CLI version, unlike Node, bash, and Compose. -COLLATERAL_TOKEN_ADDRESS="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" -PME_ADDRESS="0x5F047CCE438ae7796140506a5edf3D711034aaF3" -POINTS_ADDRESS="0x52e1b275d7f925e48f74d304e6d7e8ca489de6b9" -POINTS_HOOK_ADDRESS="0x81f47f6c84ffb1a5daa8c54989a4cb9458017188" -SAFE_OWNER_ADDRESS="0x57ac51Ad8b3B5a95e655eD2AF98D9881B136f924" -VAULT_ADDRESS="0x0730422E49B76A2D36d51304ACEcbe4f444821F8" +# `base` is the Graph manifest name, the viem chain key, and the market-maker +# YAML network name. The keeper still accepts the older `base-mainnet` spelling +# as a deprecated alias. +NETWORK=base +# ── Contracts ────────────────────────────────────────────────────────────── +BTC_USD_ADDRESS=0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F +COLLATERAL_TOKEN_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +FUTURES_ADDRESS=0xf97a1bbfb5e061ef73dad8ebf25939d93639fb7f +HASHPRICE_BTC_ADDRESS=0x70027c6f1b40e7461172af1241330b499c8c2e22 +PERPS_ADDRESS=0x794f9e63b7666985256f1d2763ee24cc0b528199 +PME_ADDRESS=0x5F047CCE438ae7796140506a5edf3D711034aaF3 +POINTS_ADDRESS=0x52e1b275d7f925e48f74d304e6d7e8ca489de6b9 +POINTS_HOOK_ADDRESS=0x81f47f6c84ffb1a5daa8c54989a4cb9458017188 +SAFE_OWNER_ADDRESS=0x57ac51Ad8b3B5a95e655eD2AF98D9881B136f924 +VAULT_ADDRESS=0x0730422E49B76A2D36d51304ACEcbe4f444821F8 + + +# ── Subgraph manifests ───────────────────────────────────────────────────── +# TODO: VAULT_START_BLOCK / POINTS_START_BLOCK need the mainnet deployment +# blocks before the prd subgraphs will index anything. + +# ── PME stress shocks (WAD-scaled) ───────────────────────────────────────── IM_SPOT_SHOCK=100000000000000000 MM_SPOT_SHOCK=100000000000000000 IM_VOL_SHOCK=50000000000000000 MM_VOL_SHOCK=50000000000000000 + +# ── Keeper runtime ───────────────────────────────────────────────────────── +DELIVERY_KEEPER_ENABLED=true +HEALTH_PORT=3000 +KEEPER_MIN_PROFIT_MARGIN=0 +LOG_LEVEL=info + +# ── Market-maker runtime ─────────────────────────────────────────────────── +MAKER_LOG_LEVEL=info diff --git a/indexer/package.json b/indexer/package.json index 0477030..ff161de 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -7,8 +7,8 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", - "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", - "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", + "prepare:env": "set -a && . \"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", + "prepare-local": "ENV_FILE=../config/dev.env pnpm prepare:env", "codegen": "graph codegen", "build": "graph build", "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ collateral-vault", @@ -18,7 +18,7 @@ "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", "test": "graph test -v 0.6.0", "lint": "biome lint .", - "indexer": "docker compose --env-file ../.env up", + "indexer": "docker compose --env-file ../config/dev.env --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground", "lint:fix": "biome check --write .", "typecheck": "graph build" diff --git a/keeper/package.json b/keeper/package.json index 16d8813..abe223f 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -4,13 +4,14 @@ "type": "module", "private": true, "engines": { - "node": ">=22.6.0" + "node": ">=22.9.0" }, "scripts": { "node": "node --import=amaro/strip", - "dev": "pnpm node --env-file=../.env src/index.ts | pino-pretty", - "dev:dry": "DRY_RUN=true pnpm node --env-file=.env src/index.ts | pino-pretty", - "start": "pnpm node --env-file=.env src/index.ts", + "dev": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env src/index.ts | pino-pretty", + "dev:dry": "DRY_RUN=true pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env src/index.ts | pino-pretty", + "prd": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/index.ts", + "start": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env src/index.ts", "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/*.test.ts' 'tests/alert/**/*.test.ts' 'tests/coordinator/**/*.test.ts' 'tests/delivery/**/*.test.ts' 'tests/discovery/**/*.test.ts' 'tests/oracle/**/*.test.ts' 'tests/pme/**/*.test.ts' 'tests/predict/**/*.test.ts' 'tests/runtime/**/*.test.ts' 'tests/tx/**/*.test.ts' 'tests/venues/**/*.test.ts'", "test:watch": "pnpm node --test --watch --test-concurrency=1 'tests/**/*.test.ts'", "pretest:integration": "pnpm node ./scripts/compile-siblings.ts", diff --git a/market-maker/package.json b/market-maker/package.json index efd1030..49258e0 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "engines": { - "node": ">=22.6.0" + "node": ">=22.9.0" }, "scripts": { "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", @@ -18,18 +18,15 @@ "perps": "pnpm node --watch src/apps/perps/main.ts", "futures": "pnpm node --watch src/apps/futures/main.ts", "portfolio": "pnpm node --watch src/apps/portfolio/main.ts", - "local:perps": "pnpm perps --config configs/perps.local.yml | pino-pretty", - "local:futures": "pnpm futures --config configs/futures.local.yml | pino-pretty", - "local:portfolio": "pnpm portfolio --config configs/portfolio.local.yml | pino-pretty", - "dev:perps": "pnpm perps --config configs/perps.dev.yml | pino-pretty", - "dev:futures": "pnpm futures --config configs/futures.dev.yml | pino-pretty", - "dev:portfolio": "pnpm portfolio --config configs/portfolio.dev.yml | pino-pretty", - "stg:perps": "pnpm perps --config configs/perps.stg.yml", - "stg:futures": "pnpm futures --config configs/futures.stg.yml", - "stg:portfolio": "pnpm portfolio --config configs/portfolio.stg.yml", - "prd:perps": "pnpm perps --config configs/perps.prd.yml", - "prd:futures": "pnpm futures --config configs/futures.prd.yml", - "prd:portfolio": "pnpm portfolio --config configs/portfolio.prd.yml", + "local:perps": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", + "local:futures": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", + "local:portfolio": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/portfolio/main.ts --config configs/portfolio.local.yml | pino-pretty", + "dev:perps": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", + "dev:futures": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", + "dev:portfolio": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/portfolio/main.ts --config configs/portfolio.dev.yml | pino-pretty", + "prd:perps": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/perps/main.ts --config configs/perps.prd.yml", + "prd:futures": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/futures/main.ts --config configs/futures.prd.yml", + "prd:portfolio": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/portfolio/main.ts --config configs/portfolio.prd.yml", "lint:fix": "biome check --write ." }, "dependencies": { diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index d17233f..6a2604e 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -1,5 +1,4 @@ import pino from "pino"; -import { loadDotenvFiles } from "../../core/env.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; @@ -19,7 +18,6 @@ import { sanitiseConfig } from "../../core/config/base.ts"; import { loadFuturesConfig } from "./config.ts"; async function main(): Promise { - loadDotenvFiles(import.meta.dirname); const config = loadFuturesConfig(); const logger = pino({ level: config.logLevel, diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index c466fab..4ef60e3 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -1,5 +1,4 @@ import pino from "pino"; -import { loadDotenvFiles } from "../../core/env.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; @@ -20,7 +19,6 @@ import { sanitiseConfig } from "../../core/config/base.ts"; import { loadPerpsConfig } from "./config.ts"; async function main(): Promise { - loadDotenvFiles(import.meta.dirname); const config = loadPerpsConfig(); const logger = pino({ level: config.logLevel, diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index 8915eec..29a70b9 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -1,5 +1,4 @@ import pino from "pino"; -import { loadDotenvFiles } from "../../core/env.ts"; import { createNetworkClients } from "../../core/client.ts"; import { WalletRegistry } from "../../core/wallet.ts"; import { OracleTracker } from "../../core/oracleTracker.ts"; @@ -177,7 +176,6 @@ function buildMarket( } async function main(): Promise { - loadDotenvFiles(import.meta.dirname); const config = loadPortfolioConfig(); const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); logger.info( diff --git a/market-maker/src/core/env.ts b/market-maker/src/core/env.ts deleted file mode 100644 index 7095061..0000000 --- a/market-maker/src/core/env.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; - -/** - * Load `.env` files at startup, in priority order: - * - * 1. market-maker/.env (closest to the app, wins) - * 2. collateral-margin/.env (shared between contracts/indexer/mm) - * - * Existing `process.env` values always take precedence over file contents - * (Node's documented behaviour for `process.loadEnvFile`), so CI/docker - * runtime env still wins. - * - * Missing files are silently skipped — `.env` is a dev convenience only. - * - * The market-maker root is two directories above `src/`. The - * collateral-margin root is three directories above `src/`. We resolve - * from `import.meta.dirname` of the caller (passed in) so the paths - * work regardless of cwd. - */ -export function loadDotenvFiles(callerDir: string): void { - // src/apps/ → src/apps → src → market-maker → collateral-margin - const marketMakerRoot = resolve(callerDir, "..", "..", ".."); - const repoRoot = resolve(marketMakerRoot, ".."); - - for (const path of [ - resolve(marketMakerRoot, ".env"), - resolve(repoRoot, ".env"), - ]) { - if (existsSync(path)) { - try { - process.loadEnvFile(path); - } catch { - // ignore parse errors — runtime config validation will catch - // truly missing values. - } - } - } -} diff --git a/points-indexer/package.json b/points-indexer/package.json index 8badcc6..6149c99 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -7,8 +7,8 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", - "prepare:env": "envsubst < subgraph.template.yaml > subgraph.yaml", - "prepare-local": "set -a && source ../.env && set +a && pnpm prepare:env", + "prepare:env": "set -a && . \"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", + "prepare-local": "ENV_FILE=../config/dev.env pnpm prepare:env", "codegen": "graph codegen", "build": "graph build", "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ points", @@ -20,7 +20,6 @@ "test:integration": "hardhat test nodejs", "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", "lint": "biome lint .", - "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground", "lint:fix": "biome check --write .", "typecheck": "tsgo --noEmit -p integration/tsconfig.json" From abd0616c2b5b1db8f4da64e7872f6457fc55cc36 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 17 Sep 2026 21:18:42 +0200 Subject: [PATCH 178/180] fix(contracts): drop run:stg now that config/stg.env is gone --- contracts/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/package.json b/contracts/package.json index 601ee3e..cf64254 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -13,7 +13,6 @@ "lint": "biome lint .", "clean": "rm -rf abi artifacts cache", "run:dev": "hardhat run --env dev", - "run:stg": "hardhat run --env stg", "run:prod": "hardhat run --env prd" }, "devDependencies": { From 186f7f4bbca40df58aca57b21c4e42bd00c97b1f Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 17 Sep 2026 21:33:28 +0200 Subject: [PATCH 179/180] fix(indexer): source ENV_FILE by an explicit relative path pnpm runs scripts with sh, which is dash on the CI runners. POSIX `.` searches PATH when its operand contains no slash, so `ENV_FILE=.env.example` failed with ".env.example: not found" while passing locally, where /bin/sh is bash and falls back to the current directory. Prefix a bare filename with ./ before sourcing, leaving paths that already contain a slash untouched. --- indexer/package.json | 3 ++- points-indexer/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/indexer/package.json b/indexer/package.json index ff161de..04fea16 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -7,7 +7,8 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", - "prepare:env": "set -a && . \"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", + "//prepare:env": "`.` searches PATH when its operand has no slash, so a bare filename must be made explicitly relative for dash, which is /bin/sh on the CI runners.", + "prepare:env": "ENV_FILE=\"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\"; case \"$ENV_FILE\" in */*) ;; *) ENV_FILE=\"./$ENV_FILE\" ;; esac; set -a && . \"$ENV_FILE\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", "prepare-local": "ENV_FILE=../config/dev.env pnpm prepare:env", "codegen": "graph codegen", "build": "graph build", diff --git a/points-indexer/package.json b/points-indexer/package.json index 6149c99..88dce6b 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -7,7 +7,8 @@ "type": "module", "scripts": { "clean": "rm -rf data generated build subgraph.yaml", - "prepare:env": "set -a && . \"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", + "//prepare:env": "`.` searches PATH when its operand has no slash, so a bare filename must be made explicitly relative for dash, which is /bin/sh on the CI runners.", + "prepare:env": "ENV_FILE=\"${ENV_FILE:?ENV_FILE must point at an env file, e.g. ../config/dev.env}\"; case \"$ENV_FILE\" in */*) ;; *) ENV_FILE=\"./$ENV_FILE\" ;; esac; set -a && . \"$ENV_FILE\" && set +a && envsubst < subgraph.template.yaml > subgraph.yaml", "prepare-local": "ENV_FILE=../config/dev.env pnpm prepare:env", "codegen": "graph codegen", "build": "graph build", From f36530a3dbbf95611903d73fde8a6f85e774b5ec Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Thu, 17 Sep 2026 23:01:00 +0200 Subject: [PATCH 180/180] fix(config): set subgraph start blocks so Goldsky can deploy An empty VAULT_START_BLOCK rendered as null in the manifest, which Goldsky rejects as an invalid i32. --- config/dev.env | 4 +--- config/prd.env | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/config/dev.env b/config/dev.env index 57cb539..149cd41 100644 --- a/config/dev.env +++ b/config/dev.env @@ -28,9 +28,7 @@ SAFE_OWNER_ADDRESS= # ── Subgraph manifests ───────────────────────────────────────────────────── POINTS_START_BLOCK=42622435 -# TODO: VAULT_START_BLOCK has never been set in the GitHub dev environment, so -# the vault subgraph has been rendering an empty startBlock. Set it to the -# CollateralVault deployment block. +VAULT_START_BLOCK=40846214 # ── PME stress shocks (WAD-scaled) ───────────────────────────────────────── IM_SPOT_SHOCK=100000000000000000 diff --git a/config/prd.env b/config/prd.env index 6c4072d..27573cf 100644 --- a/config/prd.env +++ b/config/prd.env @@ -29,8 +29,8 @@ VAULT_ADDRESS=0x0730422E49B76A2D36d51304ACEcbe4f444821F8 # ── Subgraph manifests ───────────────────────────────────────────────────── -# TODO: VAULT_START_BLOCK / POINTS_START_BLOCK need the mainnet deployment -# blocks before the prd subgraphs will index anything. +VAULT_START_BLOCK=51307389 +POINTS_START_BLOCK=51307913 # ── PME stress shocks (WAD-scaled) ───────────────────────────────────────── IM_SPOT_SHOCK=100000000000000000