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..81c1e4d --- /dev/null +++ b/.bedrock/.terragrunt/00_outputs.tf @@ -0,0 +1,32 @@ +################################################################################ +# 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 +} + +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 new file mode 100644 index 0000000..de6e509 --- /dev/null +++ b/.bedrock/.terragrunt/00_variables.tf @@ -0,0 +1,162 @@ +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, public env vars, desired count) is owned by +# deploy-col-mar-mm.yml. Private keys and the Alchemy key live in Secrets +# Manager (01_secrets_manager.tf) and are injected with ECS valueFrom. +# Both ECS service.task_definition and 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 + } +} + +################################################################################ +# UNIFIED MARGIN KEEPER - SCAFFOLDING ONLY +################################################################################ +# Single ECS service for coordinated perps + futures liquidation (replaces +# derivatives-marketplace svc-perps-keeper-*). Public runtime config is owned +# by deploy-keeper.yml from config/.env. The liquidator key, Alchemy key, +# and webhook secret are injected from Secrets Manager. +################################################################################ + +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 +################################################################################ +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" +} + +################################################################################ +# Secrets Manager (gitignored secret.auto.tfvars — never commit values) +################################################################################ +# Same shape in 02-dev and 04-lmn: +# alchemy_api_key = "..." +# liquidator_private_key = "0x..." +# futures_mm_private_key = "0x..." +# perps_mm_private_key = "0x..." +# webhook_secret = "" # optional; keeper WEBHOOK_SECRET + +variable "alchemy_api_key" { + description = "Alchemy API key injected into the keeper and both market makers" + type = string + sensitive = true +} + +variable "liquidator_private_key" { + description = "Keeper signer. Injected as LIQUIDATOR_PRIVATE_KEY" + type = string + sensitive = true +} + +variable "futures_mm_private_key" { + description = "Portfolio market-maker signer on the futures ECS service. Injected as PRIVATE_KEY" + type = string + sensitive = true +} + +variable "perps_mm_private_key" { + description = "Perps market-maker signer. Injected as PRIVATE_KEY on the perps ECS service. CI does not roll that service." + type = string + sensitive = true +} + +variable "webhook_secret" { + description = "Optional keeper WEBHOOK_SECRET. Empty string injects an empty value." + type = string + sensitive = true + default = "" +} 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..dedaca6 --- /dev/null +++ b/.bedrock/.terragrunt/01_github_actions_iam.tf @@ -0,0 +1,263 @@ +################################################################################ +# GITHUB ACTIONS IAM ROLE AND POLICIES +################################################################################ +# Bare-minimum IAM for deploy-col-mar-mm.yml and deploy-keeper.yml: +# - register new ECS task definitions +# - update ECS services to point at the new revisions +# - PassRole the existing bedrock-foundation-role into ECS tasks +# +# Public runtime config is baked into each task-def revision by the workflow +# from config/.env. Private keys are not. CI may DescribeSecret so it +# can write valueFrom ARNs; GetSecretValue stays on bedrock-foundation-role. +# +# 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 = "*" + }, + { + Sid = "DescribeFuturesMmSecret" + Effect = "Allow" + Action = [ + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.futures_mm[0].arn + ] + } + ] + }) +} + +################################################################################ +# 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 = "*" + }, + { + Sid = "DescribeKeeperSecret" + Effect = "Allow" + Action = [ + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.keeper[0].arn + ] + } + ] + }) +} diff --git a/.bedrock/.terragrunt/01_secrets_manager.tf b/.bedrock/.terragrunt/01_secrets_manager.tf new file mode 100644 index 0000000..2ff27bb --- /dev/null +++ b/.bedrock/.terragrunt/01_secrets_manager.tf @@ -0,0 +1,126 @@ +################################################################################ +# SECRETS MANAGER +################################################################################ +# ECS injects these at task start via valueFrom. The task definition stores +# the secret ARN, not the value. GitHub Actions only calls DescribeSecret +# (see 01_github_actions_iam.tf) so CI never receives the secret string. +# +# bedrock-foundation-role is the task execution role (local.titanio_role_arn). + +resource "aws_iam_policy" "col_mar_secret_access" { + count = (var.keeper_service.create || var.futures_mm_service.create || var.perps_mm_service.create) ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-secret-access-${substr(var.account_shortname, 8, 3)}" + description = "Allow ECS tasks to read Collateral Margin secrets from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = compact([ + var.keeper_service.create ? aws_secretsmanager_secret.keeper[0].arn : "", + var.futures_mm_service.create ? aws_secretsmanager_secret.futures_mm[0].arn : "", + var.perps_mm_service.create ? aws_secretsmanager_secret.perps_mm[0].arn : "", + ]) + } + ] + }) + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Collateral Margin Secret Access Policy", + Capability = null, + }, + ) +} + +resource "aws_iam_role_policy_attachment" "col_mar_secret_access" { + count = (var.keeper_service.create || var.futures_mm_service.create || var.perps_mm_service.create) ? 1 : 0 + provider = aws.use1 + role = "bedrock-foundation-role" + policy_arn = aws_iam_policy.col_mar_secret_access[0].arn +} + +################################################################################ +# Keeper +################################################################################ + +resource "aws_secretsmanager_secret" "keeper" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-keeper-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Collateral-margin keeper secrets (liquidator key, Alchemy key, webhook secret)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-keeper-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "keeper" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.keeper[0].id + secret_string = jsonencode({ + liquidator_private_key = var.liquidator_private_key + alchemy_api_key = var.alchemy_api_key + webhook_secret = var.webhook_secret + }) +} + +################################################################################ +# Futures / portfolio market maker +################################################################################ +# deploy-col-mar-mm.yml runs the portfolio app on this service and injects +# private_key as PRIVATE_KEY. + +resource "aws_secretsmanager_secret" "futures_mm" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Portfolio market-maker secrets (signer key and Alchemy key)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-futures-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "futures_mm" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.futures_mm[0].id + secret_string = jsonencode({ + private_key = var.futures_mm_private_key + alchemy_api_key = var.alchemy_api_key + }) +} + +################################################################################ +# Perps market maker +################################################################################ +# CI does not roll this service. The secret is here so the task definition +# can inject PRIVATE_KEY the same way, and so the key is not left in GitHub. + +resource "aws_secretsmanager_secret" "perps_mm" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Perps market-maker secrets (signer key and Alchemy key)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-perps-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "perps_mm" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.perps_mm[0].id + secret_string = jsonencode({ + private_key = var.perps_mm_private_key + alchemy_api_key = var.alchemy_api_key + }) +} diff --git a/.bedrock/.terragrunt/04_futures_mm_svc.tf b/.bedrock/.terragrunt/04_futures_mm_svc.tf new file mode 100644 index 0000000..4992a2c --- /dev/null +++ b/.bedrock/.terragrunt/04_futures_mm_svc.tf @@ -0,0 +1,329 @@ +################################################################################ +# 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 / public env vars / desired_count after first apply. +# PRIVATE_KEY and ALCHEMY_API_KEY are injected from Secrets Manager. +# +# 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" + } + ] + + secrets = [ + { + name = "PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.futures_mm[0].arn}:private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.futures_mm[0].arn}:alchemy_api_key::" + } + ] + + 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..f845557 --- /dev/null +++ b/.bedrock/.terragrunt/04_perps_mm_svc.tf @@ -0,0 +1,350 @@ +################################################################################ +# 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" + } + ] + + secrets = [ + { + name = "PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.perps_mm[0].arn}:private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.perps_mm[0].arn}:alchemy_api_key::" + } + ] + + 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/.terragrunt/06_col_mar_keeper_svc.tf b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf new file mode 100644 index 0000000..31a74ab --- /dev/null +++ b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf @@ -0,0 +1,321 @@ +################################################################################ +# 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, public env vars, and desired_count after +# the first CI/CD deploy. Private keys are injected from Secrets Manager. +# 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" + } + ] + + secrets = [ + { + name = "LIQUIDATOR_PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:liquidator_private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:alchemy_api_key::" + }, + { + name = "WEBHOOK_SECRET" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:webhook_secret::" + } + ] + + 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/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..c4d2dc7 --- /dev/null +++ b/.bedrock/02-dev/terraform.tfvars @@ -0,0 +1,78 @@ +######################################## +# Service Toggles - SCAFFOLDING ONLY +######################################## +# Public runtime config is config/dev.env. Private keys and the Alchemy key +# are Secrets Manager, seeded from gitignored secret.auto.tfvars: +# alchemy_api_key, liquidator_private_key, futures_mm_private_key, +# perps_mm_private_key, and optional webhook_secret. +######################################## + +create_core = true + +# Unused. Quoting runs on the futures MM service (portfolio app). create=false +# removes this empty ECS service, its internal ALB, and perpsmm.dev.hashpower.exchange. +perps_mm_service = { + create = false + 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 +} + +# 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 +######################################## +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/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..e3436bc --- /dev/null +++ b/.bedrock/04-lmn/terraform.tfvars @@ -0,0 +1,76 @@ +######################################## +# Service Toggles - SCAFFOLDING ONLY +######################################## +# Public runtime config is config/prd.env. Private keys and the Alchemy key +# are Secrets Manager, seeded from gitignored secret.auto.tfvars: +# alchemy_api_key, liquidator_private_key, futures_mm_private_key, +# perps_mm_private_key, and optional webhook_secret. +######################################## + +create_core = true + +# Unused. Quoting runs on the futures MM service. Leave false so LMN does not +# build the empty perps service, its ALB, or perpsmm.hashpower.exchange. +perps_mm_service = { + create = false + 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 +} + +keeper_service = { + create = true + task_worker_qty = 1 + cnt_port = 3000 + 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/.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= 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/infra/README.md b/.github/infra/README.md new file mode 100644 index 0000000..1e81ea0 --- /dev/null +++ b/.github/infra/README.md @@ -0,0 +1,70 @@ +# 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_ENV=prd │ │ MAKER_ENV=prd │ +│ PERPS_ADDRESS │ │ FUTURES_ADDRESS │ +│ secrets: PRIVATE_KEY (perps) │ │ secrets: PRIVATE_KEY (futures) │ +│ ALCHEMY_API_KEY │ │ ALCHEMY_API_KEY │ +└────────────────────────────────────┘ └────────────────────────────────────┘ +``` + +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). + +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. + 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/.github/infra/ecs-task.tf b/.github/infra/ecs-task.tf new file mode 100644 index 0000000..f435d33 --- /dev/null +++ b/.github/infra/ecs-task.tf @@ -0,0 +1,152 @@ +# 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.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} +# +# 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, + var.alchemy_api_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 = "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 = [{ + 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/.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 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/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml new file mode 100644 index 0000000..8a35597 --- /dev/null +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -0,0 +1,469 @@ +name: Deploy Collateral Margin Market Maker + +# 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. Renders a new task-def revision for the portfolio app (MAKER_APP=portfolio) +# with image + public config from config/.env. PRIVATE_KEY and +# ALCHEMY_API_KEY are injected by ECS from Secrets Manager (valueFrom). +# 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_DESIRED_COUNT default "1" (set 0 to halt without +# redeploy) — deploy orchestration, not +# container config +# +# GitHub Secrets (configure per-environment): +# AWS_ROLE_ARN_DEV / _LMN OIDC role ARNs from the TF output github_actions_role_arn +# SLACK_WEBHOOK_URL (org or repo level) for slack-notify +# Signer and Alchemy keys live in Secrets Manager +# (`col-mar-futures-mm-secrets-v3-`), seeded from secret.auto.tfvars. + +on: + push: + branches: + - dev + - main + - "cicd/**" + paths: + - "market-maker/**" + - "config/*.env" + - ".github/workflows/deploy-col-mar-mm.yml" + workflow_dispatch: + inputs: + environment: + description: "Target environment (dev=DEV, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - 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 }} + 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: + - 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 + # 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 (futures_mm_endpoint in 00_outputs.tf): + # 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." + ;; + main) + SUFFIX="lmn" + MAKER_ENV="prd" + URL_HOST_PREFIX="" + ;; + *) + echo "::error::Unknown environment '$ENV' (expected dev or main)" + 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 "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 + + - 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 portfolio + runs-on: ubuntu-latest + needs: build + if: needs.build.outputs.is_cicd_branch != 'true' + 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 || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarMM-portfolio-${{ github.run_id }} + + - name: Verify service exists + id: svc_check + env: + SERVICE: ${{ needs.build.outputs.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 service. 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: ${{ 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 }} + ENV_SUFFIX: ${{ needs.build.outputs.env_suffix }} + + # Deploy orchestration (not container config) + MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} + + # Computed. Signer and Alchemy come from Secrets Manager. + MAKER_APP: portfolio + MAKER_ENV: ${{ needs.build.outputs.maker_env }} + 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 }}" + echo " Cluster: ${CLUSTER}" + echo " Service: ${SERVICE}" + 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 + 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 + + # Public keys from config/.env plus values computed by this run. + # Secret names are stripped so they cannot land in the environment block. + echo "🔧 Injecting environment from ${CONFIG}..." + 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; } \ + | grep -vxE 'ALCHEMY_API_KEY|LIQUIDATOR_PRIVATE_KEY|WEBHOOK_SECRET|PRIVATE_KEY|FUTURES_MM_PRIVATE_KEY|PERPS_MM_PRIVATE_KEY' || true ) + + # 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)" + + SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "col-mar-futures-mm-secrets-v3-${ENV_SUFFIX}" \ + --region "${REGION}" \ + --query ARN --output text) + echo " Secrets from: col-mar-futures-mm-secrets-v3-${ENV_SUFFIX}" + + jq --slurpfile env env-block.json --arg arn "$SECRET_ARN" ' + .containerDefinitions[0].environment = $env[0] | + .containerDefinitions[0].secrets = [ + {"name":"PRIVATE_KEY","valueFrom":($arn + ":private_key::")}, + {"name":"ALCHEMY_API_KEY","valueFrom":($arn + ":alchemy_api_key::")} + ] + ' 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 portfolio" + + verify: + name: 🔍 Verify portfolio + runs-on: ubuntu-latest + 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 || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarMM-portfolio-Verify-${{ github.run_id }} + + - name: Wait for service to stabilize + env: + SERVICE: ${{ needs.build.outputs.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 (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 "**Service:** \`${{ needs.build.outputs.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('*Health:* <{0}|Portfolio> + + *Cluster:* `{1}` • *Service:* `{2}`', + needs.build.outputs.health_url, + needs.build.outputs.ecs_cluster, needs.build.outputs.service) }}" diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml new file mode 100644 index 0000000..24fae31 --- /dev/null +++ b/.github/workflows/deploy-keeper.yml @@ -0,0 +1,535 @@ +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 the public runtime config from config/.env. +# LIQUIDATOR_PRIVATE_KEY, ALCHEMY_API_KEY, and WEBHOOK_SECRET are injected +# by ECS from Secrets Manager (valueFrom). This workflow only resolves the +# secret ARN. +# +# 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): none for the keeper process. +# Alchemy, the liquidator key, and WEBHOOK_SECRET live in Secrets Manager +# (`col-mar-keeper-secrets-v3-`), seeded from secret.auto.tfvars. +# +# Repository secrets (all environments): +# AWS_ROLE_ARN_DEV / _LMN from terragrunt output github_actions_role_arn +# SLACK_WEBHOOK_URL optional — deploy notifications + +on: + push: + branches: + - dev + - main + - "cicd/**" + paths: + - "keeper/**" + - "config/*.env" + - ".github/workflows/deploy-keeper.yml" + workflow_dispatch: + inputs: + environment: + description: "Target environment (dev=DEV, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - 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: + 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 }} + 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 }} + 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: + - name: Checkout code + uses: actions/checkout@v5 + 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: 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 + + # 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." + CONFIG_ENV="dev" + ;; + main) + SUFFIX="lmn" + URL_HOST_PREFIX="" + CONFIG_ENV="prd" + ;; + *) + 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 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + 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@v7 + 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: + name: 🚀 Deploy + runs-on: ubuntu-latest + 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: + # 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 || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarKeeper-${{ github.run_id }} + + - name: Verify service exists + id: svc_check + env: + SERVICE: ${{ needs.build.outputs.ecs_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 " 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: Render new task definition + deploy + id: deploy + if: steps.svc_check.outputs.skip != 'true' + env: + 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 }} + CONFIG_ENV: ${{ needs.build.outputs.config_env }} + ENV_SUFFIX: ${{ needs.build.outputs.env_suffix }} + + # Public config comes from config/.env. Computed values only + # here. Signer, Alchemy, and webhook come from Secrets Manager. + KEEPER_VERSION: ${{ needs.build.outputs.version }} + KEEPER_DESIRED_COUNT: ${{ vars.KEEPER_DESIRED_COUNT }} + run: | + set -euo pipefail + CONFIG="config/${CONFIG_ENV}.env" + DESIRED_COUNT="${KEEPER_DESIRED_COUNT:-1}" + + echo "🚀 Deploying keeper to ${{ needs.build.outputs.environment }}" + echo " Cluster: ${CLUSTER}" + echo " Service: ${SERVICE}" + echo " Task Family: ${TASK_FAMILY}" + echo " Image: ${IMAGE}" + echo " Config: ${CONFIG}" + echo " Desired Count: ${DESIRED_COUNT}" + + 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(.containerDefinitions[0].command, .containerDefinitions[0].entryPoint) | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy) + ' task-def.json > new-task-def.json + + # Public keys from config/.env plus KEEPER_VERSION. Secret names + # are stripped so they cannot land in the task definition environment. + 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; } \ + | grep -vxE 'ALCHEMY_API_KEY|LIQUIDATOR_PRIVATE_KEY|WEBHOOK_SECRET|PRIVATE_KEY' || true ) + + # 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)" + + SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "col-mar-keeper-secrets-v3-${ENV_SUFFIX}" \ + --region "${REGION}" \ + --query ARN --output text) + echo " Secrets from: col-mar-keeper-secrets-v3-${ENV_SUFFIX}" + + jq --slurpfile env env-block.json --arg arn "$SECRET_ARN" ' + .containerDefinitions[0].environment = $env[0] | + .containerDefinitions[0].secrets = [ + {"name":"LIQUIDATOR_PRIVATE_KEY","valueFrom":($arn + ":liquidator_private_key::")}, + {"name":"ALCHEMY_API_KEY","valueFrom":($arn + ":alchemy_api_key::")}, + {"name":"WEBHOOK_SECRET","valueFrom":($arn + ":webhook_secret::")} + ] | + del(.containerDefinitions[0].command, .containerDefinitions[0].entryPoint) + ' new-task-def.json > final-task-def.json + + NEW_TASK_DEF=$(aws ecs register-task-definition \ + --cli-input-json file://final-task-def.json \ + --region "${REGION}" \ + --query 'taskDefinition.taskDefinitionArn' --output text) + + 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}" \ + --task-definition "${NEW_TASK_DEF}" \ + --desired-count "${DESIRED_COUNT}" \ + --region "${REGION}" \ + --force-new-deployment > /dev/null + + echo "✅ Deployment triggered" + + verify: + name: 🔍 Verify + runs-on: ubuntu-latest + needs: [build, deploy] + if: needs.build.outputs.is_cicd_branch != 'true' + + steps: + - 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 || secrets.AWS_ROLE_ARN_LMN }} + aws-region: ${{ needs.build.outputs.aws_region }} + role-session-name: GitHubActions-ColMarKeeper-Verify-${{ github.run_id }} + + - 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" \ + --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 "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." + + # 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' + 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 + runs-on: ubuntu-latest + needs: [build, verify] + if: always() && needs.build.outputs.is_cicd_branch != 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v5 + 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 }}" + 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 }} - 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 Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $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 "**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@v5 + 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 "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/deploy-points-subgraph.yml b/.github/workflows/deploy-points-subgraph.yml new file mode 100644 index 0000000..6c5d9a1 --- /dev/null +++ b/.github/workflows/deploy-points-subgraph.yml @@ -0,0 +1,473 @@ +name: Deploy points subgraph + +on: + push: + branches: + - dev + - 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 + - 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, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - 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: hpow-points) + GS_NAME="${{ vars.GOLDSKY_POINTS_SUBGRAPH_NAME }}" + if [ -z "$GS_NAME" ]; then + GS_NAME="hpow-points" + fi + echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT + + # 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 + 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 + + echo "🎯 Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" + + - name: Install dependencies + 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: + 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" + ENV_FILE=.env.example + else + ENV_FILE="../config/${CONFIG_ENV}.env" + fi + 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 + + - 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 || 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 / 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/deploy-subgraph.yml b/.github/workflows/deploy-subgraph.yml new file mode 100644 index 0000000..eb219a2 --- /dev/null +++ b/.github/workflows/deploy-subgraph.yml @@ -0,0 +1,471 @@ +name: Deploy collateral-vault subgraph + +on: + push: + branches: + - dev + - main + - "cicd/**" + paths: + - "indexer/**" + - "config/*.env" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/deploy-subgraph.yml" + pull_request: + branches: + - dev + - main + paths: + - "indexer/**" + - "config/*.env" + - "contracts/abi/CollateralVault.json" + - ".github/workflows/deploy-subgraph.yml" + workflow_dispatch: + inputs: + environment: + description: "Target environment (dev=DEV, main=LMN/PROD)" + required: true + type: choice + options: + - dev + - 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, 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 + 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 + + echo "🎯 Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" + + - name: Install dependencies + 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: + 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" + ENV_FILE=.env.example + else + ENV_FILE="../config/${CONFIG_ENV}.env" + fi + 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 + + - 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 || 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 / 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..80ef104 --- /dev/null +++ b/.github/workflows/indexer-tests.yml @@ -0,0 +1,81 @@ +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: | + ENV_FILE=.env.example pnpm prepare:env + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Codegen + run: pnpm codegen + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Run matchstick tests + run: pnpm test diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml new file mode 100644 index 0000000..5697804 --- /dev/null +++ b/.github/workflows/keeper-test.yml @@ -0,0 +1,118 @@ +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 + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout collateral-margin + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: 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 + + - name: Install keeper dependencies + working-directory: ./keeper + run: pnpm install --frozen-lockfile + + # 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 + abi="src/abi/HashPowerFutures.ts" + if [[ ! -f "$abi" ]]; then + echo "::error::$abi is missing" + exit 1 + fi + if ! grep -q 'export const HashPowerFuturesAbi' "$abi"; then + echo "::error::$abi does not export HashPowerFuturesAbi" + exit 1 + fi + echo "Vendored HashPowerFutures ABI present" + + - name: Lint + working-directory: ./keeper + run: pnpm lint + + - name: TypeCheck + working-directory: ./keeper + run: pnpm typecheck + + - name: Unit tests + working-directory: ./keeper + run: pnpm test + + - name: Install contracts dependencies + working-directory: ./contracts + run: pnpm install --frozen-lockfile + + - name: Checkout derivatives-marketplace (perps contracts) + uses: actions/checkout@v5 + with: + repository: Lumerin-protocol/derivatives-marketplace + # 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 + # 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 + 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: Integration tests + working-directory: ./keeper + env: + PERPS_REPO: ${{ github.workspace }}/perps + FUTURES_REPO: ${{ github.workspace }}/futures-marketplace + run: pnpm test:integration diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml new file mode 100644 index 0000000..d9d1611 --- /dev/null +++ b/.github/workflows/market-maker-tests.yml @@ -0,0 +1,70 @@ +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@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: market-maker/package.json + + - name: Setup Node.js + 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 + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + # 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: pnpm test diff --git a/.github/workflows/points-indexer-tests.yml b/.github/workflows/points-indexer-tests.yml new file mode 100644 index 0000000..ae0e4ce --- /dev/null +++ b/.github/workflows/points-indexer-tests.yml @@ -0,0 +1,105 @@ +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: | + ENV_FILE=.env.example pnpm prepare:env + echo "--- subgraph.yaml ---" + cat subgraph.yaml + + - name: Codegen + working-directory: ./points-indexer + run: pnpm codegen + + - name: Lint + 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 + + - name: Run matchstick unit tests + working-directory: ./points-indexer + run: pnpm test + + - name: Run integration tests + working-directory: ./points-indexer + run: pnpm test:integration diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml new file mode 100644 index 0000000..df5f75c --- /dev/null +++ b/.github/workflows/publish-collateral-abi.yml @@ -0,0 +1,207 @@ +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) +# — 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. +# +# 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: + # `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/**" + - ".github/workflows/publish-collateral-abi.yml" + # 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 + 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 + 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") + + 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" + + # 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 + 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. + # 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") + echo "version=$NEW" >> "$GITHUB_OUTPUT" + echo "Version: $NEW ($LEVEL bump from $PUBLISHED)" + + - name: Publish + if: steps.bump.outputs.level != 'none' + # --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' + 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: 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: 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: | + 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/.gitignore b/.gitignore index 48e0213..42723cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ node_modules .env .env.* -!.env.example \ No newline at end of file +!.env.example +.DS_Store + +# Terraform / Terragrunt +.terraform +.terraform.lock.hcl +.terragrunt-cache +*.out +*.plan +secret.tfvars +secret.* \ No newline at end of file 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/biome.json b/biome.json index fa4cffb..0bd64e8 100644 --- a/biome.json +++ b/biome.json @@ -7,15 +7,18 @@ "!!**/build", "!!**/node_modules", "!!**/coverage", + "!!**/cache", "!!**/artifacts", - "!!**/cache" + "!!**/dist" ] }, "overrides": [ { "includes": [ "indexer/src/**", - "indexer/tests/**" + "indexer/tests/**", + "points-indexer/src/**", + "points-indexer/tests/**" ], "linter": { "rules": { @@ -23,6 +26,7 @@ "noForEach": "off" }, "correctness": { + "noConstantCondition": "off", "noUnusedImports": "warn" }, "style": { 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..76446b9 --- /dev/null +++ b/collateral-abi/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hashpower/collateral-abi", + "version": "0.1.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..8000a18 --- /dev/null +++ b/collateral-abi/scripts/semver-diff.mjs @@ -0,0 +1,80 @@ +// 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)); + +// 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"); +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"] +} diff --git a/config/dev.env b/config/dev.env new file mode 100644 index 0000000..149cd41 --- /dev/null +++ b/config/dev.env @@ -0,0 +1,48 @@ +# 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 +VAULT_START_BLOCK=40846214 + +# ── PME stress shocks (WAD-scaled) ───────────────────────────────────────── +IM_SPOT_SHOCK=100000000000000000 +MM_SPOT_SHOCK=100000000000000000 +IM_VOL_SHOCK=50000000000000000 +MM_VOL_SHOCK=50000000000000000 + +# ── 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 new file mode 100644 index 0000000..27573cf --- /dev/null +++ b/config/prd.env @@ -0,0 +1,48 @@ +# 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. + +# `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 ───────────────────────────────────────────────────── +VAULT_START_BLOCK=51307389 +POINTS_START_BLOCK=51307913 + +# ── 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/contracts/.env.example b/contracts/.env.example index a3073bc..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 ─────────────────────────────────────────── @@ -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..8b27c0f 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -3,3 +3,6 @@ artifacts cache .env *.tsbuildinfo + +# Deploy script output (deployed addresses) +*.tmp diff --git a/contracts/abi/CollateralVault.json b/contracts/abi/CollateralVault.json index 16a5ab8..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", @@ -603,6 +613,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..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", @@ -603,6 +613,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/abi/ContractErrors.json b/contracts/abi/ContractErrors.json index 9045af7..d140f22 100644 --- a/contracts/abi/ContractErrors.json +++ b/contracts/abi/ContractErrors.json @@ -1,4 +1,14 @@ [ + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -10,6 +20,11 @@ "name": "Error", "type": "error" }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, { "inputs": [ { @@ -21,6 +36,11 @@ "name": "OwnableUnauthorizedAccount", "type": "error" }, + { + "inputs": [], + "name": "NotFinalized", + "type": "error" + }, { "inputs": [ { @@ -37,6 +57,11 @@ "name": "ZeroAmount", "type": "error" }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, { "inputs": [ { @@ -70,6 +95,36 @@ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TransfersDisabled", + "type": "error" + }, { "inputs": [], "name": "MarginBreach", @@ -86,6 +141,11 @@ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, { "inputs": [ { @@ -129,6 +189,16 @@ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "FailedCall", @@ -149,6 +219,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 +283,26 @@ "name": "ERC20InvalidReceiver", "type": "error" }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "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 6fad5b6..91a5946 100644 --- a/contracts/abi/ContractErrors.ts +++ b/contracts/abi/ContractErrors.ts @@ -1,4 +1,14 @@ export const contractErrors = [ + { + "inputs": [], + "name": "NoPoints", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -10,6 +20,11 @@ export const contractErrors = [ "name": "Error", "type": "error" }, + { + "inputs": [], + "name": "EmptyPool", + "type": "error" + }, { "inputs": [ { @@ -21,6 +36,11 @@ export const contractErrors = [ "name": "OwnableUnauthorizedAccount", "type": "error" }, + { + "inputs": [], + "name": "NotFinalized", + "type": "error" + }, { "inputs": [ { @@ -37,6 +57,11 @@ export const contractErrors = [ "name": "ZeroAmount", "type": "error" }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, { "inputs": [ { @@ -70,6 +95,36 @@ export const contractErrors = [ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [], + "name": "InsufficientGov", + "type": "error" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LinearMarketNotRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TransfersDisabled", + "type": "error" + }, { "inputs": [], "name": "MarginBreach", @@ -86,6 +141,11 @@ export const contractErrors = [ "name": "ERC20InvalidSpender", "type": "error" }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, { "inputs": [ { @@ -129,6 +189,16 @@ export const contractErrors = [ "name": "FunctionDisabled", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, { "inputs": [], "name": "FailedCall", @@ -149,6 +219,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 +283,26 @@ export const contractErrors = [ "name": "ERC20InvalidReceiver", "type": "error" }, + { + "inputs": [], + "name": "AlreadyEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, + { + "inputs": [], + "name": "OracleNotSet", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", diff --git a/contracts/abi/IPoints.json b/contracts/abi/IPoints.json new file mode 100644 index 0000000..9bb566b --- /dev/null +++ b/contracts/abi/IPoints.json @@ -0,0 +1,83 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "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" + } +] diff --git a/contracts/abi/IPoints.ts b/contracts/abi/IPoints.ts new file mode 100644 index 0000000..1b1e333 --- /dev/null +++ b/contracts/abi/IPoints.ts @@ -0,0 +1,83 @@ +export const IPointsAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "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" + } +] as const; diff --git a/contracts/abi/IPointsHook.json b/contracts/abi/IPointsHook.json new file mode 100644 index 0000000..1769c29 --- /dev/null +++ b/contracts/abi/IPointsHook.json @@ -0,0 +1,63 @@ +[ + { + "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" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "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..b7f9c0a --- /dev/null +++ b/contracts/abi/IPointsHook.ts @@ -0,0 +1,63 @@ +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" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "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/IPortfolioMarginEngine.json b/contracts/abi/IPortfolioMarginEngine.json index 41d8e7d..ecb685b 100644 --- a/contracts/abi/IPortfolioMarginEngine.json +++ b/contracts/abi/IPortfolioMarginEngine.json @@ -37,6 +37,49 @@ "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": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "imSpotShock", @@ -50,6 +93,44 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "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 +143,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..138323c 100644 --- a/contracts/abi/IPortfolioMarginEngine.ts +++ b/contracts/abi/IPortfolioMarginEngine.ts @@ -37,6 +37,49 @@ 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": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "imSpotShock", @@ -50,6 +93,44 @@ 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": [ + { + "internalType": "uint256", + "name": "notional", + "type": "uint256" + } + ], + "name": "linearOrderMargin", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "mmSpotShock", @@ -62,5 +143,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/Points.json b/contracts/abi/Points.json new file mode 100644 index 0000000..47fd1f8 --- /dev/null +++ b/contracts/abi/Points.json @@ -0,0 +1,568 @@ +[ + { + "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": "InsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, + { + "inputs": [], + "name": "TransfersDisabled", + "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": "BURNER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "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": [ + { + "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": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "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": "pure", + "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": "pure", + "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" + } +] diff --git a/contracts/abi/Points.ts b/contracts/abi/Points.ts new file mode 100644 index 0000000..601d65d --- /dev/null +++ b/contracts/abi/Points.ts @@ -0,0 +1,568 @@ +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": "InsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "name": "MintingFinalized", + "type": "error" + }, + { + "inputs": [], + "name": "TransfersDisabled", + "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": "BURNER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "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": [ + { + "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": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "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": "pure", + "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": "pure", + "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" + } +] as const; diff --git a/contracts/abi/PointsHook.json b/contracts/abi/PointsHook.json new file mode 100644 index 0000000..f66aadd --- /dev/null +++ b/contracts/abi/PointsHook.json @@ -0,0 +1,567 @@ +[ + { + "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": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "keeperPoints", + "type": "uint256" + } + ], + "name": "KeeperPointsSet", + "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": "maxMakerMult", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxSpread", + "type": "uint256" + } + ], + "name": "PriceImprovementSet", + "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": "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": [ + { + "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": [], + "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", + "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" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "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": [ + { + "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": "_keeperPoints", + "type": "uint256" + } + ], + "name": "setKeeperPoints", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minFee", + "type": "uint256" + } + ], + "name": "setMinFee", + "outputs": [], + "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": [ + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + } + ], + "name": "setWeights", + "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": "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..94e168a --- /dev/null +++ b/contracts/abi/PointsHook.ts @@ -0,0 +1,567 @@ +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": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "keeperPoints", + "type": "uint256" + } + ], + "name": "KeeperPointsSet", + "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": "maxMakerMult", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxSpread", + "type": "uint256" + } + ], + "name": "PriceImprovementSet", + "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": "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": [ + { + "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": [], + "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", + "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" + }, + { + "internalType": "uint256", + "name": "makerPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refPrice", + "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": [ + { + "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": "_keeperPoints", + "type": "uint256" + } + ], + "name": "setKeeperPoints", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minFee", + "type": "uint256" + } + ], + "name": "setMinFee", + "outputs": [], + "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": [ + { + "internalType": "uint256", + "name": "_wMaker", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_wTaker", + "type": "uint256" + } + ], + "name": "setWeights", + "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": "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/abi/PortfolioMarginEngine.json b/contracts/abi/PortfolioMarginEngine.json index 3746747..fc0df67 100644 --- a/contracts/abi/PortfolioMarginEngine.json +++ b/contracts/abi/PortfolioMarginEngine.json @@ -36,16 +36,46 @@ "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": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -84,6 +114,11 @@ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", @@ -94,25 +129,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 +180,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 +291,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_market", + "type": "address" + } + ], + "name": "addLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -305,16 +366,59 @@ "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": "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" }, @@ -344,17 +448,37 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "initializeV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "address", - "name": "_vault", + "name": "user", "type": "address" } ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", + "name": "isHealthy", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", "type": "function" }, { @@ -365,7 +489,7 @@ "type": "address" } ], - "name": "isHealthy", + "name": "isLiquidatable", "outputs": [ { "internalType": "bool", @@ -376,6 +500,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 +558,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 +592,10 @@ }, { "inputs": [], - "name": "perpsDex", + "name": "priceOracle", "outputs": [ { - "internalType": "contract IHashPowerPerpsDEX", + "internalType": "contract AggregatorV3Interface", "name": "", "type": "address" } @@ -454,22 +616,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 +652,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..baaa180 100644 --- a/contracts/abi/PortfolioMarginEngine.ts +++ b/contracts/abi/PortfolioMarginEngine.ts @@ -36,16 +36,46 @@ 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": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -84,6 +114,11 @@ export const PortfolioMarginEngineAbi = [ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", @@ -94,25 +129,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 +180,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 +291,19 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_market", + "type": "address" + } + ], + "name": "addLinearMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -305,16 +366,59 @@ 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": "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" }, @@ -344,17 +448,37 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "initializeV2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "address", - "name": "_vault", + "name": "user", "type": "address" } ], - "name": "initialize", - "outputs": [], - "stateMutability": "nonpayable", + "name": "isHealthy", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", "type": "function" }, { @@ -365,7 +489,7 @@ export const PortfolioMarginEngineAbi = [ "type": "address" } ], - "name": "isHealthy", + "name": "isLiquidatable", "outputs": [ { "internalType": "bool", @@ -376,6 +500,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 +558,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 +592,10 @@ export const PortfolioMarginEngineAbi = [ }, { "inputs": [], - "name": "perpsDex", + "name": "priceOracle", "outputs": [ { - "internalType": "contract IHashPowerPerpsDEX", + "internalType": "contract AggregatorV3Interface", "name": "", "type": "address" } @@ -454,22 +616,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 +652,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/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": { + "./*": "./*" + } +} diff --git a/contracts/contracts/CollateralVault.sol b/contracts/contracts/CollateralVault.sol index 74d8885..ef46a0b 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"; @@ -29,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 ────────────────────────────────────────────────────────────── @@ -46,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; @@ -99,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); @@ -129,13 +171,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); } @@ -198,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/Points.sol b/contracts/contracts/Points.sol new file mode 100644 index 0000000..324936e --- /dev/null +++ b/contracts/contracts/Points.sol @@ -0,0 +1,134 @@ +// 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. + /// @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 new file mode 100644 index 0000000..b2be49b --- /dev/null +++ b/contracts/contracts/PointsHook.sol @@ -0,0 +1,187 @@ +// 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. +/// +/// 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"); + + /// @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; + /// @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 ───────────────────────────────────────────────────────── + + error ZeroAddress(); + + 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). + /// @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, + uint256 makerPrice, + uint256 refPrice + ) external override onlyRole(HOOK_CALLER_ROLE) { + // Self-match exclusion: a wallet trading with itself earns nothing. + if (maker == taker) return; + + // 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); + } + } + + // Maker side. A negative makerFee (rebate) earns nothing and violates the + // 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 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 + override + onlyRole(HOOK_CALLER_ROLE) + { + uint256 amount = keeperPoints; + if (amount > 0) { + points.mint(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); + } + + /// @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/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/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index bf64c97..3bfec98 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,66 @@ contract PortfolioMarginEngine is UUPSUpgradeable, OwnableUpgradeable { - uint256 private constant WAD = 1e18; - string public constant VERSION = "1.0.0"; + using EnumerableSet for EnumerableSet.AddressSet; + + uint256 private constant MAX_ORACLE_STALENESS = 1 hours; + string public constant VERSION = "2.1.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 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 + /// implied by the setter that reverted. + error InvalidDependency(); // ── Initializer ───────────────────────────────────────────────────────── @@ -66,22 +133,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 +173,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 (or deregister) the perps DEX. Pass address(0) to disable. - function setPerps(address _perpsEngine) external onlyOwner { - perpsDex = IHashPowerPerpsDEX(_perpsEngine); - emit PerpsDexUpdated(_perpsEngine); + /// @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 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,89 +253,204 @@ 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 + /// 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); } + /// @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; } - // ── Internal ──────────────────────────────────────────────────────────── + /// @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); + 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); - function _computeMargin(address user, bool isIM) private view returns (uint256) { - // 1. Aggregate net Greeks (WAD-scaled) - (int256 netDelta, uint256 netGamma, uint256 netVega) = _aggregateGreeks(user); + inputs.linear.buyOrderDelta = 0; + inputs.linear.sellOrderDelta = 0; + inputs.linear.fillLoss = 0; + uint256 withoutOrders = _marginFromInputs(inputs, true, spotPrice); - // 2. Four-scenario stress loss (WAD-scaled) - uint256 worstLoss = _worstStressLoss(netDelta, netGamma, netVega, isIM); + return withOrders > withoutOrders ? withOrders - withoutOrders : 0; + } - // 3. Perps add-ons (optional) - uint256 perpOrderMargin = 0; - uint256 unrealizedLoss = 0; - uint256 fundingOwed = 0; - if (address(perpsDex) != address(0)) { - perpOrderMargin = perpsDex.getOrderMargin(user); + /// @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. 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++) { + if (ILinearMarket(linearMarkets.at(i)).hasRestingOrderDelta(user)) return true; + } + return false; + } - int256 perpPnl = perpsDex.getUnrealizedPnl(user); - unrealizedLoss = perpPnl < 0 ? uint256(-perpPnl) : 0; + // ── Internal ──────────────────────────────────────────────────────────── - int256 pendingFunding = perpsDex.getPendingFunding(user); - fundingOwed = pendingFunding > 0 ? uint256(pendingFunding) : 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; + } + + struct MarginInputs { + LinearAggregate linear; + int256 netDelta; + int256 netGamma; + int256 netVega; + uint256 optionsReserved; + } + + function _computeMargin(address user, bool isIM) private view returns (uint256) { + return _marginFromAggregate(user, _linearAggregate(user), isIM); + } - // 4. Options reserved margin (WAD → token decimals, optional) - uint256 optReservedTokens = 0; + /// @dev Fold options into an already-collected linear snapshot. + function _marginInputs(address user, LinearAggregate memory agg) + private + view + returns (MarginInputs memory inputs) + { + inputs.linear = agg; + inputs.netDelta = agg.netDelta; if (address(optionsEngine) != address(0)) { - optReservedTokens = _fromWad(optionsEngine.getOptionsReservedMargin(user)); + (int256 optDelta, int256 optGamma, int256 optVega) = optionsEngine.getNetGreeks(user); + inputs.netDelta += optDelta; + inputs.netGamma = optGamma; + inputs.netVega = optVega; + inputs.optionsReserved = M.fromWad(optionsEngine.getOptionsReservedMargin(user), collateralDecimals); } + } - // 5. Futures add-ons (optional) - uint256 futuresOrderMargin = 0; - uint256 futuresUnrealizedLoss = 0; - if (address(futures) != address(0)) { - futuresOrderMargin = futures.getFuturesOrderMargin(user); - int256 futuresPnl = futures.getFuturesUnrealizedPnl(user); - futuresUnrealizedLoss = futuresPnl < 0 ? uint256(-futuresPnl) : 0; - } + /// @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()); + } - // Convert stress loss from WAD to token decimals - uint256 stressTokens = _fromWad(worstLoss); + 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( + 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; - return stressTokens + perpOrderMargin + futuresOrderMargin + optReservedTokens + unrealizedLoss - + futuresUnrealizedLoss + fundingOwed; - } + // Convert stress loss from WAD to token decimals + uint256 stressTokens = M.fromWad(worstLoss, collateralDecimals); - /// @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(deliveryDurationDays * qty) * WAD per active position (optional) - if (address(futures) != address(0)) { - netDelta += futures.getNetPositionDelta(user); - } + return stressTokens + agg.fillLoss + inputs.optionsReserved + 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); } } - /// @dev Evaluate 4 stress scenarios and return the worst-case loss (WAD). - /// Scenarios: (±Δs, ±Δσ) where Δs = spotShock * spotPrice (dollar move) + /// @dev Return the worst loss over (±Δs, ±Δσ), where each linear term is minimized + /// independently and the gamma term is unchanged across spot directions. /// PnL ≈ delta·Δs + ½·gamma·Δs² + vega·Δσ - function _worstStressLoss(int256 netDelta, uint256 netGamma, uint256 netVega, bool isIM) + function _worstStressLoss(int256 netDelta, int256 netGamma, int256 netVega, bool isIM, uint256 spotPrice) private view returns (uint256 worst) @@ -220,71 +459,130 @@ contract PortfolioMarginEngine is uint256 volShock = isIM ? imVolShock : mmVolShock; // Convert percentage shock → dollar move (WAD) - uint256 spotPrice = _getSpotPriceWad(); uint256 deltaS = spotShockFrac * spotPrice / WAD; // Pre-compute gamma term: ½ · gamma · Δs² - uint256 gammaTerm = netGamma * deltaS / WAD * deltaS / (2 * WAD); - - // Scenario 1: spot +, vol + - worst = _scenarioLoss(netDelta, gammaTerm, netVega, int256(deltaS), int256(volShock)); + int256 gammaTerm = netGamma * int256(deltaS) / int256(WAD) * int256(deltaS) / int256(2 * WAD); + + 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; + } - // 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 `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(); } - /// @dev Compute loss for a single scenario. Returns max(0, -PnL) in WAD. - /// PnL = delta·Δs/WAD + gammaTerm + vega·Δσ/WAD - /// Note: gammaTerm is pre-computed and always the same magnitude across ±spotShock - /// (quadratic in |Δs|), so we always ADD it regardless of direction. - function _scenarioLoss(int256 netDelta, uint256 gammaTerm, uint256 netVega, int256 deltaS, int256 deltaVol) - private - pure - returns (uint256) - { - int256 deltaPnl = netDelta * deltaS / int256(WAD); - int256 vegaPnl = int256(netVega) * deltaVol / int256(WAD); - // Gamma term is ½γ(Δs)² — always non-negative, always adds to P&L - // (positive gamma profits from moves, negative gamma loses) - int256 pnl = deltaPnl + int256(gammaTerm) + vegaPnl; - return pnl < 0 ? uint256(-pnl) : 0; + // ── 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(); } - /// @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()); + 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. 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 || updatedAt == 0 || updatedAt > block.timestamp) revert InvalidOracle(); + if (block.timestamp - updatedAt > MAX_ORACLE_STALENESS) revert OracleStale(); + 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, int256, int256 + ) { } 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 8589180..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): -/// 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. -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`). - 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); - - /// @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); - - /// @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..0dee54d --- /dev/null +++ b/contracts/contracts/interfaces/ILinearMarket.sol @@ -0,0 +1,78 @@ +// 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); + + /// @notice Whether this market reports any currently margin-relevant resting-order delta. + /// @dev This narrow read keeps portfolio-wide orders-first liquidation checks off the + /// substantially more expensive position, oracle, and fill-loss path in `getRiskView`. + function hasRestingOrderDelta(address user) external view returns (bool); +} diff --git a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol index 8894fd2..b2686a6 100644 --- a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol +++ b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol @@ -1,11 +1,18 @@ // 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 { - function getNetGreeks(address user) external view returns (int256 netDelta, uint256 netGamma, uint256 netVega); + /// @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, int256 netGamma, int256 netVega); function getOptionsReservedMargin(address user) external view returns (uint256); } 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..3e679a2 --- /dev/null +++ b/contracts/contracts/interfaces/IPointsHook.sol @@ -0,0 +1,40 @@ +// 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). + /// @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. + /// @param fee The liquidator fee earned (collateral decimals); informational. + function onLiquidation(address liquidator, uint256 fee) external; +} diff --git a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol index d5e6376..f59b6d5 100644 --- a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol +++ b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol @@ -1,16 +1,66 @@ // 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 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 + /// 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/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/contracts/contracts/mocks/FuturesMock.sol b/contracts/contracts/mocks/FuturesMock.sol new file mode 100644 index 0000000..25bd81a --- /dev/null +++ b/contracts/contracts/mocks/FuturesMock.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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 +/// 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 => int256) private _unrealizedPnl; + 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; + + /// @dev See `PerpsDEXMock.vault`. Must be set before registering with a PME. + ICollateralVault public vault; + + function setVault(ICollateralVault _vault) external { + vault = _vault; + } + + function setNetPositionDelta(address user, int256 delta) external { + _netDelta[user] = delta; + } + + function getNetPositionDelta(address user) external view returns (int256) { + return _netDelta[user]; + } + + /// @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 setUnrealizedPnl(address user, int256 pnl) external { + _unrealizedPnl[user] = pnl; + } + + 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] + }); + } + + function hasRestingOrderDelta(address user) external view returns (bool) { + return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; + } +} 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/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..57d03ea 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,30 @@ 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; + } + + /// @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; + } + + /// @dev MM is always zero here, and a balance below zero is impossible. + function isLiquidatable(address) external pure returns (bool) { + return false; + } + function imSpotShock() external pure returns (uint256) { return 0; } diff --git a/contracts/contracts/mocks/OptionsEngineMock.sol b/contracts/contracts/mocks/OptionsEngineMock.sol index a998f2b..a175e3b 100644 --- a/contracts/contracts/mocks/OptionsEngineMock.sol +++ b/contracts/contracts/mocks/OptionsEngineMock.sol @@ -1,20 +1,28 @@ // 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 contract OptionsEngineMock is IOptionsEnginePortfolioView { struct Greeks { int256 netDelta; - uint256 netGamma; - uint256 netVega; + int256 netGamma; + int256 netVega; } mapping(address => Greeks) private _greeks; mapping(address => uint256) private _reserved; - function setNetGreeks(address user, int256 delta, uint256 gamma, uint256 vega) external { + /// @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, int256 gamma, int256 vega) external { _greeks[user] = Greeks(delta, gamma, vega); } @@ -22,7 +30,7 @@ contract OptionsEngineMock is IOptionsEnginePortfolioView { _reserved[user] = amount; } - function getNetGreeks(address user) external view returns (int256, uint256, uint256) { + function getNetGreeks(address user) external view returns (int256, int256, int256) { Greeks memory g = _greeks[user]; return (g.netDelta, g.netGamma, g.netVega); } diff --git a/contracts/contracts/mocks/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index 4bd4b18..c446c32 100644 --- a/contracts/contracts/mocks/PerpsDEXMock.sol +++ b/contracts/contracts/mocks/PerpsDEXMock.sol @@ -1,84 +1,97 @@ // 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 { - uint8 public constant QUANTITY_DECIMALS = 6; +contract PerpsDEXMock is ILinearMarket { + struct Position { + int256 netQuantity; + int256 netEntryValue; + } - 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; + uint8 public constant QUANTITY_DECIMALS = 6; - function decimals() external pure returns (uint8) { - return 6; // USDC - } + /// @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 setMarketPrice(uint256 price) external { - _marketPrice = price; + function setVault(ICollateralVault _vault) external { + vault = _vault; } - function getMarketPrice() external view returns (uint256) { - return _marketPrice; - } + mapping(address => Position) private _positions; + mapping(address => int256) private _unrealizedPnl; + mapping(address => int256) private _pendingFunding; + mapping(address => uint256) private _buyOrderDelta; + mapping(address => uint256) private _sellOrderDelta; + mapping(address => uint256) private _buyOrderFillLoss; + mapping(address => uint256) private _sellOrderFillLoss; + bool private _riskViewDisabled; function setUserPosition(address user, int256 qty, uint256 entryPrice) external { - _positions[user] = Position(qty, entryPrice); - } - - function setBalance(address user, uint256 bal) external { - _balances[user] = bal; + int256 netEntryValue = qty * int256(entryPrice) / int256(10 ** QUANTITY_DECIMALS); + _positions[user] = Position(qty, netEntryValue); } function setUnrealizedPnl(address user, int256 pnl) external { _unrealizedPnl[user] = pnl; } - function setMargins(address user, uint256 im, uint256 mm) external { - _initialMargin[user] = im; - _maintenanceMargin[user] = mm; - } - function getUserPosition(address user) external view returns (Position memory) { 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) { + if (_riskViewDisabled) revert(); + 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; } - function isLiquidatable(address user) external view returns (bool) { - if (_positions[user].netQuantity == 0) return false; - return _balances[user] < _maintenanceMargin[user]; + /// @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 setRiskViewDisabled(bool disabled) external { + _riskViewDisabled = disabled; + } + + function hasRestingOrderDelta(address user) external view returns (bool) { + return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; } } diff --git a/contracts/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/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..9d6b839 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -1,19 +1,29 @@ import { configVariable, defineConfig } from "hardhat/config"; import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem"; -import codegenPlugin from "./plugins/codegen/index.ts"; -import { tryLoadEnvFile } from "./lib/env.ts"; - -tryLoadEnvFile("./../.env"); -tryLoadEnvFile(".env"); +import hardhatViemAbi from "hardhat-viem-abi"; +import envLoader from "./plugins/env-loader/index.ts"; export default defineConfig({ - plugins: [hardhatToolboxViem, codegenPlugin], + 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" }, contracts: [ "CollateralVault", "ICollateralVault", "PortfolioMarginEngine", "IPortfolioMarginEngine", + "Points", + "IPoints", + "PointsHook", + "IPointsHook", + "PointsRedeemer", ], }, paths: { @@ -43,7 +53,7 @@ export default defineConfig({ etherscan: { apiKey: configVariable("ETHERSCAN_API_KEY"), enabled: true, - }, + } }, networks: { hardhat: { @@ -63,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/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 acf6ab0..cf64254 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -9,25 +9,28 @@ "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", - "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:prod": "hardhat run --env prd" }, "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", + "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": { "@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 + "packageManager": "pnpm@11.22.0" +} 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/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; + } +} diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 1ac727d..2b9c202 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -18,24 +18,33 @@ 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.0 - version: 5.0.4(15ef5180f08ad93849a5892a76f3228f) + specifier: ^5.0.7 + version: 5.0.7(60d46a9bbe0fb85b88e4a35be160bb97) '@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.52.2(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 + hardhat-viem-abi: + 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 @@ -376,40 +385,40 @@ 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==} @@ -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,17 @@ 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-utils@4.1.5': + resolution: {integrity: sha512-EokhnMFDkDQPSsxrzyDAuCJuMbEsNWfMIMPgeB+FY8wnArl+dZR7tkd5UV2iooSlyxF+H3zj/0RKReXscTyijQ==} - '@nomicfoundation/hardhat-vendored@3.0.2': - resolution: {integrity: sha512-v65aSwA0k15QzMUL4cFXT2CPnrjrxR1BE2V24BjNCPnlhwI/2e1Gfy7TaIGSor5yZGeiQ5ScI+wP/ovKxQBr0g==} + '@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 +487,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 +586,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 +754,16 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - hardhat@3.4.2: - resolution: {integrity: sha512-6NUzfGFwHdaAvNmSwsZngH1vuUd8MWN92yTM4uo2VsrRGF5bsF1GWbZd7+UJO6/8zYOwViEbWYu7J4ypmSNr/g==} + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi: + 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: + hardhat: ^3.0.0 + viem: ^2.0.0 + + hardhat@3.9.1: + resolution: {integrity: sha512-yg+0oH5tWqdsxITh6fAJjAWOSHOkC2VPlsJDJwoifs2QS1t7kyRciMy5O2F846qzH+4iqRn1rbv/5voykX3RSQ==} hasBin: true has-flag@4.0.0: @@ -771,8 +840,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: @@ -874,8 +943,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: @@ -894,8 +963,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 @@ -906,8 +975,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 @@ -1223,58 +1292,54 @@ 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': + '@nomicfoundation/hardhat-errors@3.0.17': dependencies: - '@nomicfoundation/hardhat-utils': 4.0.5 - transitivePeerDependencies: - - supports-color + '@nomicfoundation/hardhat-utils': 4.1.5 - '@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-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-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-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.52.2(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) - transitivePeerDependencies: - - supports-color + hardhat: 3.9.1 + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) - '@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-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 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,27 +1347,25 @@ 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 - '@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.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 - transitivePeerDependencies: - - supports-color + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + hardhat: 3.9.1 '@nomicfoundation/hardhat-node-test-reporter@3.0.3': dependencies: @@ -1310,94 +1373,84 @@ 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-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) - hardhat: 3.4.2 + '@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.4(15ef5180f08ad93849a5892a76f3228f)': + '@nomicfoundation/hardhat-toolbox-viem@5.0.7(60d46a9bbe0fb85b88e4a35be160bb97)': 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.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.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.4.2 - viem: 2.47.10(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/hardhat-utils@4.0.5': + '@nomicfoundation/hardhat-utils@4.1.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 - '@nomicfoundation/hardhat-vendored@3.0.2': {} + '@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.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.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) - transitivePeerDependencies: - - supports-color + '@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.52.2(typescript@5.9.3)(zod@3.25.76) - '@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.52.2(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 - viem: 2.47.10(typescript@5.9.3)(zod@3.25.76) - transitivePeerDependencies: - - supports-color + '@nomicfoundation/hardhat-errors': 3.0.17 + '@nomicfoundation/hardhat-utils': 4.1.5 + hardhat: 3.9.1 + viem: 2.52.2(typescript@5.9.3)(zod@3.25.76) '@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: '@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 @@ -1493,6 +1546,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 +1706,22 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - hardhat@3.4.2: + 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: - '@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) + 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 + '@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 +1733,6 @@ snapshots: zod: 3.25.76 transitivePeerDependencies: - bufferutil - - supports-color - utf-8-validate has-flag@4.0.0: {} @@ -1666,9 +1752,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: @@ -1717,7 +1803,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 @@ -1812,16 +1898,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: @@ -1831,8 +1917,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/contracts/pnpm-workspace.yaml b/contracts/pnpm-workspace.yaml new file mode 100644 index 0000000..17496f7 --- /dev/null +++ b/contracts/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: false + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi: true diff --git a/contracts/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); +}); diff --git a/contracts/scripts/deploy-points.ts b/contracts/scripts/deploy-points.ts new file mode 100644 index 0000000..42940d3 --- /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_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"); + 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_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_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?"); + 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)); + + 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 ───────────────────────────────────────────────────────────────── + 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/scripts/deploy-portfolio-margin-engine.ts b/contracts/scripts/deploy-portfolio-margin-engine.ts index df6c2c3..4792520 100644 --- a/contracts/scripts/deploy-portfolio-margin-engine.ts +++ b/contracts/scripts/deploy-portfolio-margin-engine.ts @@ -12,11 +12,12 @@ 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_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"); + const PRICE_ORACLE_ADDRESS = readOptionalAddress("PRICE_ORACLE_ADDRESS"); const imSpotShock = readOptionalBigInt("IM_SPOT_SHOCK"); const mmSpotShock = readOptionalBigInt("MM_SPOT_SHOCK"); @@ -33,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", { @@ -44,9 +45,10 @@ 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)", + PriceOracle: PRICE_ORACLE_ADDRESS ?? "(none)", }); if (overrideShocks) { @@ -81,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(), @@ -99,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) { @@ -126,10 +133,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.addLinearMarket (perps)", { market: PERPS_ADDRESS }); await logPrompt("Proceed?"); - const sim = await pme.simulate.setPerps([PERPS_DEX_ADDRESS]); + const sim = await pme.simulate.addLinearMarket([PERPS_ADDRESS]); const receipt = await writeAndWait(deployer, sim); logStep("Done", txUrl(pc, receipt.transactionHash)); } @@ -141,9 +148,24 @@ 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.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)); + } + + if (getAddress(data.vault) !== getAddress(VAULT_ADDRESS)) { + logInfo("PME.setVault", { vault: VAULT_ADDRESS }); await logPrompt("Proceed?"); - const sim = await pme.simulate.setFutures([FUTURES_ADDRESS]); + const sim = await pme.simulate.setVault([VAULT_ADDRESS]); const receipt = await writeAndWait(deployer, sim); logStep("Done", txUrl(pc, receipt.transactionHash)); } @@ -153,7 +175,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 }); 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 8dd25fe..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)], @@ -428,6 +486,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", () => { diff --git a/contracts/tests/crossMarginIntegration.test.ts b/contracts/tests/crossMarginIntegration.test.ts index 2042cd5..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 }), @@ -218,6 +220,120 @@ describe("Cross-Margin Integration", () => { }); }); + describe("futures leg in cross-margin engine", () => { + // 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 () => { + const { vault, alice, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + // 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_DELTA]); + + 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_DELTA]); + 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 * 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( + vault.write.withdraw([withdrawAmount], { account: alice.account }), + vault, + "Withdrawn", + [getAddress(aliceAddr), withdrawAmount, getAddress(alice.account.address)], + ); + }); + + it("sums same-side order delta across futures and perps before stressing", async () => { + const { pme, perpsMock, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + const futuresLoss = -2_500_000_000n; + // 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.setOrderDeltas([aliceAddr, 300_000n, 0n]); + + const im = await pme.read.computePortfolioIM([aliceAddr]); + 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 () => { + const { pme, futuresMock, aliceAddr } = await networkHelpers.loadFixture( + deployCrossMarginIntegrationFixture, + ); + + assert.equal(await pme.read.isHealthy([aliceAddr]), true, "no positions = healthy"); + + // 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. 5e7 delta * 5% * $50k = $125k. + await futuresMock.write.setNetPositionDelta([aliceAddr, 5n * 10n ** 7n]); + 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..83e7445 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,21 +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", []); + // 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]); - return { perpsMock, optionsMock, pme }; + await pme.write.setOracle([oracleMock.address]); + return { perpsMock, optionsMock, futuresMock, oracleMock, pme }; } /** CollateralVault tests: fund alice, bob, engine; approvals for deposit flows. */ @@ -89,14 +98,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, oracleMock, 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, oracleMock, pme, usdc, user, owner }; } /** End-to-end: vault + PME + product mocks, Alice funded and deposited. */ @@ -104,11 +116,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 +137,7 @@ export async function deployCrossMarginIntegrationFixture(conn: NetworkConnectio pme, perpsMock, optionsMock, + futuresMock, usdc, owner, alice, diff --git a/contracts/tests/gas-portfolioMarginEngine.test.ts b/contracts/tests/gas-portfolioMarginEngine.test.ts new file mode 100644 index 0000000..eb68752 --- /dev/null +++ b/contracts/tests/gas-portfolioMarginEngine.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { encodeFunctionData } from "viem"; +import { DEFAULT_MARKET_PRICE, deployPortfolioMarginEngineFixture } from "./fixtures.js"; + +const { networkHelpers, viem } = await network.connect(); + +describe("Gas: PortfolioMarginEngine", () => { + it("computePortfolioIM representative portfolio", async () => { + const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, 1_000_000n, DEFAULT_MARKET_PRICE]); + await perpsMock.write.setOrderDeltas([user, 500_000n, 250_000n]); + await optionsMock.write.setNetGreeks([ + user, + 100_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + ]); + + const publicClient = await viem.getPublicClient(); + const gas = await publicClient.estimateGas({ + account: user, + to: pme.address, + data: encodeFunctionData({ + abi: pme.abi, + functionName: "computePortfolioIM", + args: [user], + }), + }); + console.log(` computePortfolioIM representative: ${gas.toLocaleString()} gas`); + assert.ok(gas > 0n); + }); + + it("orderMarginOf no resting orders", async () => { + const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, 1_000_000n, DEFAULT_MARKET_PRICE]); + await optionsMock.write.setNetGreeks([ + user, + 100_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + ]); + + const publicClient = await viem.getPublicClient(); + const gas = await publicClient.estimateGas({ + account: user, + to: pme.address, + data: encodeFunctionData({ + abi: pme.abi, + functionName: "orderMarginOf", + args: [user], + }), + }); + console.log(` orderMarginOf no orders: ${gas.toLocaleString()} gas`); + assert.equal(await pme.read.orderMarginOf([user]), 0n); + }); +}); diff --git a/contracts/tests/points.test.ts b/contracts/tests/points.test.ts new file mode 100644 index 0000000..40cf279 --- /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.read.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.read.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.read.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..bd2da7b --- /dev/null +++ b/contracts/tests/pointsHook.test.ts @@ -0,0 +1,168 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { + KEEPER_POINTS, + NOTIONAL, + W_MAKER, + W_TAKER, + deployHookFixture, +} from "./pointsFixtures.js"; + +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) / 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, 0n, 0n], { + 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, 0n, 0n], { + 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, 0n, 0n], { + 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, 0n, 0n], { + 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, 0n, 0n], { + 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("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); + 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..208a0f4 --- /dev/null +++ b/contracts/tests/pointsIntegrationFixtures.ts @@ -0,0 +1,132 @@ +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, 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 }, + accounts: { owner, alice, bob, carol, venue, keeper, pc }, + roles: { MINTER_ROLE, BURNER_ROLE, HOOK_CALLER_ROLE }, + }; +} + +export type PointsStackFixture = Awaited>; 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/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index 8ecad07..5dba1ef 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; @@ -27,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, @@ -68,6 +87,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 +182,191 @@ 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, + ); + // 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, ); - const orderMargin = 2_000_000_000n; + // 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]); + + 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]); - await perpsMock.write.setOrderMargin([user, orderMargin]); 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("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, + ); + + 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]); - assert.equal(im, orderMargin, "order margin adds to IM"); + // 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.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", + ); }); }); @@ -137,8 +411,10 @@ describe("PortfolioMarginEngine", () => { await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); const im = await pme.read.computePortfolioIM([user]); const mm = await pme.read.computePortfolioMM([user]); + const [combinedIm, combinedMm] = await pme.read.computePortfolioMargins([user]); assert.ok(im > mm, "IM > MM for same position"); + assert.deepEqual([combinedIm, combinedMm], [im, mm], "combined read matches standalone margins"); }); }); @@ -169,6 +445,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 +491,191 @@ 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("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( @@ -204,14 +689,13 @@ describe("PortfolioMarginEngine", () => { }); it("short gamma increases stress loss", async () => { - const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + const { pme, optionsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, ); - await perpsMock.write.setUserPosition([user, 0n, 0n]); - await optionsMock.write.setNetGreeks([user, 0n, 0n, 0n]); + await optionsMock.write.setNetGreeks([user, 0n, -WAD, 0n]); const im = await pme.read.computePortfolioIM([user]); - assert.equal(im, 0n, "delta-neutral, no gamma/vega → 0 margin"); + assert.ok(im > 0n, "negative gamma loses under either spot move"); }); it("vega exposure adds to margin", async () => { @@ -225,5 +709,16 @@ describe("PortfolioMarginEngine", () => { assert.ok(im > 0n, "pure vega position has positive stress margin"); assert.equal(im, 100_000n, "vega stress = vega * volShock in token decimals"); }); + + it("short vega is stressed in the opposite volatility scenario", async () => { + const { pme, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await optionsMock.write.setNetGreeks([user, 0n, 0n, -WAD]); + const im = await pme.read.computePortfolioIM([user]); + + assert.equal(im, 100_000n, "negative vega loses under the positive vol shock"); + }); }); }); diff --git a/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. diff --git a/docs/liquidation-orchestration.md b/docs/liquidation-orchestration.md new file mode 100644 index 0000000..fa9cd96 --- /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 +`getOrderMargin`, `getUnrealizedPnl`, `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/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/docs/points-system-design.md b/docs/points-system-design.md new file mode 100644 index 0000000..33700f3 --- /dev/null +++ b/docs/points-system-design.md @@ -0,0 +1,260 @@ +# 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 (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. + +## 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, 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. + +```solidity +interface IPointsHook { + function onFill( + address maker, + address taker, + uint256 notional, + int256 makerFee, + 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: + +- 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 (address(hook) != address(0)) { + // `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()); +} +``` + +- **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 + +- **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. Every mint is also counted (`mintCount`) and recorded as a `PointsMint`. + +Design — the subgraph indexes **the points contracts only**, not the venue contracts: + +- **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 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` / `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 + +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 `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`, `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"] -->|"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), 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/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/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..04fea16 --- /dev/null +++ b/indexer/package.json @@ -0,0 +1,39 @@ +{ + "name": "vault-indexer", + "license": "UNLICENSED", + "engines": { + "node": ">=22.6.0" + }, + "type": "module", + "scripts": { + "clean": "rm -rf data generated build 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", + "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 -v 0.6.0", + "lint": "biome lint .", + "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" + }, + "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", + "matchstick-as": "0.6.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" +} diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml new file mode 100644 index 0000000..aada280 --- /dev/null +++ b/indexer/pnpm-lock.yaml @@ -0,0 +1,3669 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@graphprotocol/graph-ts': + 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) + '@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'} + + '@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==} + + '@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': {} + + '@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': + 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 {} 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..5237901 --- /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 pnpm-workspace.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", "src/index.ts"] diff --git a/keeper/README.md b/keeper/README.md new file mode 100644 index 0000000..b40a99a --- /dev/null +++ b/keeper/README.md @@ -0,0 +1,252 @@ +# 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 # 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) + perps.ts # Perps adapter (HashPowerPerpsDEX) + futures.ts # Futures adapter (expirationAt → 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 liveness + GET /ready readiness 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 | `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 | +| `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 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 | +| `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 | `/health` and `/ready` 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. + +## 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 + +``` +$ 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 (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 +- `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 +- `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..abe223f --- /dev/null +++ b/keeper/package.json @@ -0,0 +1,39 @@ +{ + "name": "collateral-margin-keeper", + "version": "0.1.0", + "type": "module", + "private": true, + "engines": { + "node": ">=22.9.0" + }, + "scripts": { + "node": "node --import=amaro/strip", + "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", + "test:integration": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/integration/**/*.test.ts'", + "typecheck": "tsgo --noEmit", + "lint": "biome lint .", + "docker": "docker build -t collateral-margin-keeper .", + "lint:fix": "biome check --write ." + }, + "dependencies": { + "@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#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi", + "pino": "^10.3.1", + "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" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml new file mode 100644 index 0000000..7a38658 --- /dev/null +++ b/keeper/pnpm-lock.yaml @@ -0,0 +1,573 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hashpower/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#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#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi + pino: + specifier: ^10.3.1 + version: 10.3.1 + viem: + 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 + '@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==} + + '@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] + + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin': + 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'} + + '@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.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==} + 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.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/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts/abi: + 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: + 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/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-JkJW4F+2DgaUOFLgP0OO9QuvJl4bh4m5gle+CVlKH8o+WBRqY2+c3OoFl7ml9lzdwGGwWjOFdchqY4KP/a6OnQ==, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535} + version: 0.0.0 + + end-of-stream@1.4.5: + 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==} + + 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.30: + resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==} + 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.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + viem@2.54.6: + resolution: {integrity: sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + 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 + +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 + + '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin': {} + + '@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.20.0': + 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.10: {} + + atomic-sleep@1.0.0: {} + + 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/8b7ed0f3572d0ea8039a11757b7c1b963be75535#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: {} + + help-me@5.0.0: {} + + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + + 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.30: + 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.2.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.2.0: + dependencies: + real-require: 1.0.0 + + undici-types@6.21.0: {} + + 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.21.0) + ox: 0.14.30 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + wrappy@1.0.2: {} + + ws@8.21.0: {} diff --git a/keeper/pnpm-workspace.yaml b/keeper/pnpm-workspace.yaml new file mode 100644 index 0000000..18d9b4c --- /dev/null +++ b/keeper/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +autoInstallPeers: false +blockExoticSubdeps: false diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts new file mode 100644 index 0000000..431457f --- /dev/null +++ b/keeper/scripts/audit-indexer-sync.ts @@ -0,0 +1,179 @@ +/** + * Audit indexer netQuantityAfter against on-chain getActiveExpirationDates 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 { HashPowerFuturesAbi } from "../src/abi/HashPowerFutures.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; + expirationAt: 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 + expirationAt + 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: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates", + args: [USER as Address], + blockNumber: BigInt(blockNumber), + }); + return (ids as readonly bigint[]).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 getActiveExpirationDates().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/compile-siblings.ts b/keeper/scripts/compile-siblings.ts new file mode 100644 index 0000000..78872b7 --- /dev/null +++ b/keeper/scripts/compile-siblings.ts @@ -0,0 +1,90 @@ +#!/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 – 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). + */ +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, "..", "..", ".."); + +/** 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: contractsPackageDir(process.env.PERPS_REPO, resolve(workspaceRoot, "perps")), + }, + { + name: "futures-marketplace", + dir: contractsPackageDir( + process.env.FUTURES_REPO, + resolve(workspaceRoot, "futures-marketplace"), + ), + }, +]; + +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/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts new file mode 100644 index 0000000..2d14848 --- /dev/null +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -0,0 +1,88 @@ +/** + * One-off diagnostic: replicate `DeliveryCoordinator.bootstrapFromUsers` + * 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 } from "viem"; +import { baseSepolia, base, hardhat } from "viem/chains"; +import { HashPowerFuturesAbi } from "../src/abi/HashPowerFutures.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, hardhat }; +const chain = CHAINS[NETWORK as keyof typeof CHAINS]; + +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("\n--- Stage 1: getActiveExpirationDates via multicall ---"); +const dateLists = await client.multicall({ + contracts: USERS.map((u) => ({ + address: FUTURES, + abi: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates" as const, + args: [u] as const, + })), + allowFailure: false, +}); + +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 expirationAt of dates) { + console.log(` ${expirationAt}`); + pairs.push({ user, expirationAt }); + } +} + +if (pairs.length === 0) { + console.log("\nNo positions found — bootstrap would return early."); + process.exit(0); +} + +console.log(`\n--- Stage 2: getUserPosition for ${pairs.length} aggregates ---`); +const positions = await client.multicall({ + contracts: pairs.map((p) => ({ + address: FUTURES, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition" as const, + args: [p.user, p.expirationAt] 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); + +let live = 0; +let pastDue = 0; +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 >= pair.expirationAt; + if (due) pastDue++; + console.log( + ` ${pair.user} @ ${pair.expirationAt}: qty=${pos.netQuantity} entryValue=${pos.netEntryValue}` + + (due ? " PAST_DUE" : ""), + ); +} +console.log(`\nlive aggregates: ${live}, past-due: ${pastDue}`); 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/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/abi/HashPowerFutures.ts b/keeper/src/abi/HashPowerFutures.ts new file mode 100644 index 0000000..f4d4ae3 --- /dev/null +++ b/keeper/src/abi/HashPowerFutures.ts @@ -0,0 +1,1979 @@ +export const HashPowerFuturesAbi = [ + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "ArrayLengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyBatch", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateNotAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateShouldBeInTheFuture", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMarginBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidFee", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidQty", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReduceQuantity", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimeInForce", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantPerExpirationReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NotLiquidatable", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToUser", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "OrdersStillOpen", + "type": "error" + }, + { + "inputs": [], + "name": "OverLiquidation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "PositionExpirationNotStartedYet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "SettlementDateNotReached", + "type": "error" + }, + { + "inputs": [], + "name": "TimeInForceNotFilled", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnsupportedTokenDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int256", + "name": "min", + "type": "int256" + }, + { + "internalType": "int256", + "name": "max", + "type": "int256" + } + ], + "name": "ValueOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BadDebt", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newFutureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "FutureExpirationDatesCountUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "hook", + "type": "address" + } + ], + "name": "HookUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidationFeeBps", + "type": "uint16" + } + ], + "name": "LiquidationFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newLiquidationMarginPercent", + "type": "uint8" + } + ], + "name": "LiquidationMarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidatorShareBps", + "type": "uint16" + } + ], + "name": "LiquidatorShareBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newMakerFeeBps", + "type": "int16" + } + ], + "name": "MakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newOracle", + "type": "address" + } + ], + "name": "OracleUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + } + ], + "name": "OrderCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "OrderCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "OrderLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "makerOrderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tradePrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "makerEntryPriceAfter", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "takerEntryPriceAfter", + "type": "uint256" + } + ], + "name": "OrderMatched", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "name": "OrderUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newPortfolioMargin", + "type": "address" + } + ], + "name": "PortfolioMarginUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidatorFee", + "type": "uint256" + } + ], + "name": "PositionLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "settlementPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "settledBy", + "type": "address" + } + ], + "name": "PositionSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "recordedBy", + "type": "address" + } + ], + "name": "SettlementPriceRecorded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newTakerFeeBps", + "type": "int16" + } + ], + "name": "TakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "CONTRACT_SIZE_HPS_DAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXPIRATION_INTERVAL_DAYS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORACLE_STALENESS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORDERS_PER_PARTICIPANT_PER_EXPIRATION", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PRICE_LEVELS_PER_SIDE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "QUANTITY_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collectedFeesBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "_tif", + "type": "uint8" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "createOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "dropActiveOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "expirationIntervalDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "firstFutureExpirationDate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureExpirationDatesCount", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getActiveExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMarketPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "getOrder", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getOrderAggregateAtExpiration", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyValue", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellValue", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderAggregate", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxLevels", + "type": "uint256" + } + ], + "name": "getOrderBookPrices", + "outputs": [ + { + "internalType": "uint256[]", + "name": "bids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "asks", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isBid", + "type": "bool" + } + ], + "name": "getQuantityAtPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getRiskView", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netPositionDelta", + "type": "int256" + }, + { + "internalType": "int256", + "name": "unrealizedPnl", + "type": "int256" + }, + { + "internalType": "int256", + "name": "pendingFunding", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "buyOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyOrderFillLoss", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderFillLoss", + "type": "uint256" + } + ], + "internalType": "struct ILinearMarket.RiskView", + "name": "view_", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserOrdersAtExpiration", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "orderIds", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserPosition", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netQuantity", + "type": "int256" + }, + { + "internalType": "int256", + "name": "netEntryValue", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hook", + "outputs": [ + { + "internalType": "contract IPointsHook", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_priceOracle", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_firstFutureExpirationDate", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "liquidateOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "liquidateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_closeQty", + "type": "uint256" + } + ], + "name": "liquidatePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "_closeQtys", + "type": "uint256[]" + } + ], + "name": "liquidatePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidatorShareBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "makerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "portfolioMargin", + "outputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "priceOracle", + "outputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "recordSettlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "_newQuantity", + "type": "int256" + } + ], + "name": "reduceOrderSize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "removeOutdatedOrders", + "outputs": [ + { + "internalType": "uint256", + "name": "removed", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "resetState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "setFutureExpirationDatesCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_hook", + "type": "address" + } + ], + "name": "setHook", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidationFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + } + ], + "name": "setLiquidationMarginPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidatorShareBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_makerFeeBps", + "type": "int16" + } + ], + "name": "setMakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", + "type": "address" + } + ], + "name": "setOracle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "_pm", + "type": "address" + } + ], + "name": "setPortfolioMargin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_takerFeeBps", + "type": "int16" + } + ], + "name": "setTakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "settlePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_users", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + } + ], + "name": "settlePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "settlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + } + ], + "name": "simulateOrder", + "outputs": [ + { + "internalType": "int256", + "name": "filledQuantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "averageFillPrice", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "remainingQuantity", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "takerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_cancelIds", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.ReduceIntent[]", + "name": "_reduces", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "updateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawCollectedFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/keeper/src/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..8ac57ed --- /dev/null +++ b/keeper/src/chain.ts @@ -0,0 +1,44 @@ +import { + createPublicClient, + createWalletClient, + http, + type Account, + type Chain as ViemChain, + type PublicClient, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, baseSepolia, hardhat } from "viem/chains"; +import type { Config, NetworkName } from "./config.ts"; + +export interface Chain { + publicClient: PublicClient; + walletClient: WalletClient; + 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, +}; + +/** + * 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 chain = VIEM_CHAINS[config.chain.network]; + const transport = http(config.chain.rpcUrl); + const publicClient = createPublicClient({ chain, transport }); + const account = privateKeyToAccount(config.keeper.privateKey); + const walletClient = createWalletClient({ account, chain, transport }); + + return { publicClient, walletClient, account }; +} diff --git a/keeper/src/config.ts b/keeper/src/config.ts new file mode 100644 index 0000000..6b545d2 --- /dev/null +++ b/keeper/src/config.ts @@ -0,0 +1,416 @@ +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 type NetworkName = "hardhat" | "base-sepolia" | "base"; + +export const SUPPORTED_NETWORKS: readonly NetworkName[] = [ + "hardhat", + "base-sepolia", + "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` + * 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; + 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; + /** + * Max futures expiry legs closed per `liquidatePositions` tx (gas-bounded + * chunking). `reduceToTarget` sends ONE worst-first chunk of at most this + * 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. + */ + maxLotsPerLiquidationTx: number; + }; + 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; + /** + * 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. */ + 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; + /** + * 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; + }; + delivery: { + /** + * Opt-in: when true, the keeper permissionlessly calls + * `settlePosition(positionId)` on every active futures position the moment + * 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. + * + * `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; + /** + * Cadence of the periodic safety-net sweep over tracked positions. Picks + * up anything the per-position timers missed (process restarts, missed + * `OrderMatched` events, clock skew). Live timers are the hot path. + */ + sweepIntervalMs: number; + /** + * 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; + /** + * 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 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 + * conservative (~15M gas worst case). Set lower if your participants + * have unusually expensive settlement paths. + */ + maxBatchSize: number; + }; +} + +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); +} + +/** + * 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. + */ +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; +} + +/** + * 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 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 "${raw}"`, + ); + } + 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"; + } + + // Alchemy keeps its own spelling for mainnet. + const alchemySubdomain: Record, string> = { + "base-sepolia": "base-sepolia", + base: "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"]; + if (!["events", "webhook", "both"].includes(discoveryMode)) { + throw new Error(`DISCOVERY_MODE must be one of events|webhook|both, got "${discoveryMode}"`); + } + + const network = requireNetwork(); + + return { + version: process.env.KEEPER_VERSION ?? "dev", + chain: { + network, + rpcUrl: resolveRpcUrl(network), + 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"), + maxLotsPerLiquidationTx: Number( + process.env.FUTURES_MAX_LOTS_PER_LIQUIDATION_TX ?? "50", + ), + }, + pme: { address: requireAddress("PME_ADDRESS") }, + oracle: { + 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), + 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", + 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"), + }, + delivery: { + enabled: process.env.DELIVERY_KEEPER_ENABLED === "true", + 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/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..ed8f487 --- /dev/null +++ b/keeper/src/coordinator/planner.ts @@ -0,0 +1,346 @@ +import type { Address } from "viem"; +import type pino from "pino"; +import type { Chain } from "../chain.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"; + +/** 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; + positionsClosed?: number; + 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. + * + * 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 + * 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 + * skip predicates. + */ +export class Planner { + /** + * 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 = 64; + + // 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. 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 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 = actionable[0]; + log.info( + { + venue: worst.venue.name, + unrealizedLoss: worst.totalLoss, + positionCount: worst.positionCount, + }, + "Position-leg: reducing worst venue down to the IM buffer", + ); + const result = await worst.venue.reduceToTarget(user); + + if ("feeEarned" in result) { + positionsClosed += result.positionsClosed; + totalFee += result.feeEarned; + reports.push({ + kind: "positionLeg", + venue: worst.venue.name, + feeEarned: result.feeEarned, + positionsClosed: result.positionsClosed, + }); + } 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 { + // `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, + skipped: result.skipped, + }); + if (result.skipped === "nothingToClose") exhausted.add(worst.venue.name); + } + + 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, 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. + */ + 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; + } + + /** + * 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. + * + * 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, + ): Promise> { + const ranked: Array<{ + venue: Venue; + totalLoss: bigint; + totalNotional: bigint; + positionCount: number; + }> = []; + for (const venue of this.venues) { + const positions = await venue.readPositions(user); + 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 ranked; + } + + 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/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts new file mode 100644 index 0000000..f24f1cd --- /dev/null +++ b/keeper/src/delivery/coordinator.ts @@ -0,0 +1,721 @@ +import { + BaseError, + ContractFunctionRevertedError, + getAddress, + type Address, + type Hex, + type Log, +} from "viem"; +import { withUnstickRetry } from "../tx/unstick.ts"; +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"; + +/** + * 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. + * + * Authorization: `settlePosition` is permissionless. + * + * Hot path is event-driven: + * + * OrderMatched ─▶ re-index maker + taker active expiries + * PositionSettled ─▶ drop that (user, expirationAt) from the index + * timer fires ─▶ settle matured tracked aggregates + * + * Cold-start safety net: + * + * bootstrapFromUsers(addrs) ─▶ getActiveExpirationDates + getUserPosition + * backfill(fromBlock) ─▶ replay OrderMatched / PositionSettled + * sweep() ─▶ periodic settle of past-due tracked rows + */ +export class DeliveryCoordinator { + /** 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(); + 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 { + if (this.running) return; + this.running = true; + + this.logger.info( + { signer: this.chain.account.address }, + "delivery coordinator starting (permissionless settlePosition)", + ); + + 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(); + }, this.config.delivery.sweepIntervalMs); + } + + 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(); + this.disposeExpiryIndex?.(); + this.disposeExpiryIndex = undefined; + + for (const u of this.unwatchers) { + try { + u(); + } catch (err) { + this.logger.warn( + { err }, + "delivery: unwatcher threw — continuing shutdown", + ); + } + } + this.unwatchers = []; + } + + /** + * 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) { + 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", + ); + + let chunkErrors = 0; + for (let start = fromBlock; start <= head; start += chunkSize) { + const end = start + chunkSize - 1n > head ? head : start + chunkSize - 1n; + try { + const [matched, settled] = await Promise.all([ + this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName: "OrderMatched", + fromBlock: start, + toBlock: end, + }), + this.chain.publicClient.getContractEvents({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + eventName: "PositionSettled", + fromBlock: start, + toBlock: end, + }), + ]); + this.onOrderMatched(matched as unknown as readonly Log[]); + this.onPositionSettled(settled 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 aggregate belonging + * to `users` and index them. + */ + 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; + } + + let indexed = 0; + for (const user of users) { + indexed += await this.indexUserPositionsInternal(user); + } + + const pastDue = this.countPastDuePositions(); + const nextDueAt = this.findEarliestExpirationAt(); + this.logger.info( + { + users: users.length, + indexed, + total: this.tracked.size, + pastDue, + nextDueAt: + nextDueAt !== undefined + ? new Date(Number(nextDueAt) * 1000).toISOString() + : null, + }, + "delivery bootstrap: complete", + ); + + await this.sweep(); + } + + private countPastDuePositions(): number { + const nowSec = BigInt(Math.floor(Date.now() / 1000)); + let n = 0; + for (const pos of this.tracked.values()) { + if (nowSec >= pos.expirationAt) n++; + } + return n; + } + + private findEarliestExpirationAt(): bigint | undefined { + let earliest: bigint | undefined; + for (const pos of this.tracked.values()) { + if (earliest === undefined || pos.expirationAt < earliest) + earliest = pos.expirationAt; + } + return earliest; + } + + /** Single-user index — wired from `tracker.onAdded`. Never throws. */ + async indexUserPositions(user: Address): Promise { + try { + const indexed = await this.indexUserPositionsInternal(user); + 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: indexUserPositions failed"); + } + } + + private async indexUserPositionsInternal(user: Address): Promise { + let expirationAts: readonly bigint[]; + try { + expirationAts = (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 }, "delivery: getActiveExpirationDates failed"); + return 0; + } + if (expirationAts.length === 0) return 0; + + const positions = (await this.chain.publicClient.multicall({ + contracts: expirationAts.map((expirationAt) => ({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition" as const, + args: [user, expirationAt] as const, + })), + allowFailure: false, + })) as readonly { netQuantity: bigint; netEntryValue: bigint }[]; + + let added = 0; + 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, expirationAt)) added++; + } + return added; + } + + /** Insert or refresh a tracked aggregate. Returns true if newly added. */ + 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), + expirationAt, + }; + this.tracked.set(key, tracked); + this.scheduleTimer(tracked); + return true; + } + + 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) { + 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(trackKey(pos.user, pos.expirationAt))) continue; + if (nowSec < pos.expirationAt) continue; + candidates.push(pos); + } + + if (candidates.length === 0) { + this.logger.debug( + { tracked: this.tracked.size, pendingFuture: this.tracked.size }, + "delivery sweep: nothing past-due", + ); + return; + } + this.logger.info( + { candidates: candidates.length, tracked: this.tracked.size }, + "delivery sweep: settling", + ); + + const max = Math.max(1, this.config.delivery.maxBatchSize); + for (let i = 0; i < candidates.length; i += max) { + const slice = candidates.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", + ); + } + } + } + + size(): number { + return this.tracked.size; + } + + /** Public for tests. */ + has(user: Address, expirationAt: bigint): boolean { + return this.tracked.has(trackKey(user, expirationAt)); + } + + 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.expirationAt); + if (this.inflight.has(key)) continue; + fresh.push({ user: getAddress(pos.user), expirationAt: pos.expirationAt }); + this.inflight.add(key); + } + if (fresh.length === 0) return; + const next = this.txChain.then(() => this.attemptBatch(fresh)); + this.txChain = next.catch(() => undefined); + try { + await next; + } finally { + for (const pos of fresh) { + this.inflight.delete(trackKey(pos.user, pos.expirationAt)); + } + } + } + + private async attemptBatch(positions: readonly TrackedPosition[]): Promise { + type SimParams = Parameters< + typeof this.chain.publicClient.simulateContract + >[0]; + const simResults = await Promise.allSettled( + positions.map((pos) => + this.chain.publicClient.simulateContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "settlePosition", + args: [pos.user, pos.expirationAt], + account: this.chain.account, + } as unknown as SimParams), + ), + ); + + 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(pos); + continue; + } + const decoded = decodeRecoverableRevert(r.reason); + if (decoded !== undefined) { + this.logRecoverableRevert(decoded, pos); + if (decoded === "PositionNotExists") { + this.dropTracked(pos.user, pos.expirationAt); + } + continue; + } + this.logger.error( + { err: r.reason, user: pos.user, expirationAt: pos.expirationAt.toString() }, + "delivery: simulate failed with non-recoverable error — skipping from batch", + ); + } + + if (settleable.length === 0) { + this.logger.debug( + { batchSize: positions.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.settlePositions", + ); + for (const pos of settleable) this.dropTracked(pos.user, pos.expirationAt); + return; + } + + const users = settleable.map((pos) => pos.user); + const expirationAts = settleable.map((pos) => pos.expirationAt); + + type WriteParams = Parameters< + typeof this.chain.walletClient.writeContract + >[0]; + let hash: Hex; + try { + hash = await withUnstickRetry(this.chain, this.logger, () => + this.chain.walletClient.writeContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "settlePositions", + args: [users, expirationAts], + account: this.chain.account, + chain: this.chain.walletClient.chain ?? null, + } as unknown as WriteParams), + ); + } catch (err) { + if (isTransientTxError(err)) { + this.logger.warn( + { err, batchSize: settleable.length }, + "delivery batch: tx submission failed transiently — sweep will retry", + ); + return; + } + this.logger.warn( + { err, batchSize: settleable.length }, + "delivery batch: write reverted — falling back to per-position retries", + ); + for (const pos of settleable) { + try { + await this.attemptSettle(pos); + } catch (innerErr) { + this.logger.error( + { err: innerErr, user: pos.user, expirationAt: pos.expirationAt.toString() }, + "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: settleable.length, + ...formatGasCost(receipt, this.ethUsdFeed), + }, + "delivery batch: settlePositions confirmed", + ); + + for (const pos of settleable) { + this.dropTracked(pos.user, pos.expirationAt); + } + } + + private async attemptSettle(pos: TrackedPosition): Promise { + const args = [pos.user, pos.expirationAt] as const; + + 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({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "settlePosition", + 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, pos); + if (decoded === "PositionNotExists") { + this.dropTracked(pos.user, pos.expirationAt); + } + return; + } + throw err; + } + + if (this.config.keeper.dryRun) { + this.logger.info( + { user: pos.user, expirationAt: pos.expirationAt.toString() }, + "[dryRun] would call settlePosition", + ); + this.dropTracked(pos.user, pos.expirationAt); + return; + } + + 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, + }); + this.logger.info( + { + user: pos.user, + expirationAt: pos.expirationAt.toString(), + hash, + blockNumber: receipt.blockNumber.toString(), + ...formatGasCost(receipt, this.ethUsdFeed), + }, + "delivery: settlePosition confirmed", + ); + this.dropTracked(pos.user, pos.expirationAt); + } + + private onOrderMatched(logs: readonly Log[]): void { + type Args = { + maker?: Address; + taker?: Address; + expirationAt?: bigint; + makerNetQtyAfter?: bigint; + takerNetQtyAfter?: bigint; + }; + const users = new Set
(); + for (const raw of logs) { + 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.expirationAt !== undefined) { + if (args.maker !== undefined && args.makerNetQtyAfter !== undefined) { + 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.expirationAt); + else this.upsertTracked(args.taker, args.expirationAt); + } 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); + } + } + for (const user of users) { + void this.indexUserPositions(user); + } + } + + 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.dropTracked(args.user, args.expirationAt); + } + } + + private logRecoverableRevert(revert: RecoverableRevert, pos: TrackedPosition): void { + if (revert === "PositionNotExists") { + this.logger.info( + { 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, expirationAt: pos.expirationAt.toString(), revert }, + "delivery: settlePosition skipped (transient revert, will retry)", + ); + } + + private scheduleTimer(pos: TrackedPosition): void { + const key = trackKey(pos.user, pos.expirationAt); + const existing = this.timers.get(key); + if (existing !== undefined) clearTimeout(existing); + + const targetMs = + Number(pos.expirationAt) * 1000 + this.config.delivery.settleDelayMs; + const delayMs = Math.max(0, targetMs - Date.now()); + if (delayMs > MAX_TIMEOUT_MS) { + return; + } + const timer = setTimeout(() => { + void this.sweep().catch((err) => { + this.logger.error( + { err, user: pos.user, expirationAt: pos.expirationAt.toString() }, + "delivery: timer-fired sweep threw", + ); + }); + }, delayMs); + if (typeof timer.unref === "function") timer.unref(); + this.timers.set(key, timer); + } +} + +export function trackKey(user: Address, expirationAt: bigint): string { + return `${getAddress(user).toLowerCase()}:${expirationAt.toString()}`; +} + +interface TrackedPosition { + user: Address; + expirationAt: bigint; +} + +const MAX_TIMEOUT_MS = 2_147_483_647; + +type RecoverableRevert = + | "PositionNotExists" + | "PositionExpirationNotStartedYet" + | "OracleStale" + | "InvalidOracle"; + +const RECOVERABLE_REVERTS = new Set([ + "PositionNotExists", + "PositionExpirationNotStartedYet", + "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; +} + +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") || + haystack.includes("timeout") || + haystack.includes("econnreset") || + haystack.includes("etimedout") || + haystack.includes("socket hang up") + ); +} + +export const __testing = { decodeRecoverableRevert, isTransientTxError, trackKey }; 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 new file mode 100644 index 0000000..0cb92f5 --- /dev/null +++ b/keeper/src/discovery/tracker.ts @@ -0,0 +1,426 @@ +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 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"; + +/** + * 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 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 + * 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 = ParticipantListener; + +export class ParticipantTracker implements ParticipantSource { + 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), + }), + ); + } + + /** 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 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()` + * 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 history is deliberately excluded: its bounded per-expiry replay + * lives in `FuturesExpiryIndex`. + * + * 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[]); + }, + }, + ]; + + 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 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 + * 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); + } + } + +} 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/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..9fad1af --- /dev/null +++ b/keeper/src/index.ts @@ -0,0 +1,284 @@ +import pino from "pino"; +import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; +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"; +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 { 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"; + +/** + * 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, + serializers: { err: serializeError }, + }); + + logger.info( + { + 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"); + + // 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, ethUsdFeed), + new FuturesVenue(chain, config, logger, ethUsdFeed), + ]; + + 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, + participants, + 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, + participants, + queue, + executor, + priceFeed, + logger, + notifier, + ); + + let webhookIngester: WebhookIngester | undefined; + if (config.chain.discoveryMode !== "events") { + webhookIngester = new WebhookIngester(config, tracker, logger); + } + + // 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; + if (config.delivery.enabled) { + deliveryCoordinator = new DeliveryCoordinator( + chain, + 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 + // 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); + + // 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.) + participants.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(); + balanceMonitor.stop(); + ethUsdFeed?.stop(); + deliveryCoordinator?.stop(); + futuresExpiryIndex.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 ───────────────────────────────────────────────────────────── + // 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. + 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(); + await futuresExpiryIndex.start(); + if (webhookIngester !== undefined) await webhookIngester.start(); + if (deliveryCoordinator !== undefined) await deliveryCoordinator.start(); + await executor.start(); + scheduler.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 + // 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(); + health.markReady(); + + // 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 (participants.size() > 0 && predictor.size() === 0) { + logger.warn( + { tracked: participants.size() }, + "tracker has users but predictor index is empty — snapshot path may be failing; check earlier 'rebuild failed' logs", + ); + } + + logger.info( + { + tracked: participants.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/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/oracle/priceFeed.ts b/keeper/src/oracle/priceFeed.ts new file mode 100644 index 0000000..eadff8f --- /dev/null +++ b/keeper/src/oracle/priceFeed.ts @@ -0,0 +1,185 @@ +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()`. 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`, + * 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; + } + + // 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; + + 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..1ac787c --- /dev/null +++ b/keeper/src/pme/health.ts @@ -0,0 +1,107 @@ +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"; +import { PortfolioMarginEngineAbi } from "collateral-margin-abi/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..4fe11cf --- /dev/null +++ b/keeper/src/predict/coordinator.ts @@ -0,0 +1,411 @@ +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 { 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"; +import { readAccountSnapshot, readMMParams } from "./snapshot.ts"; +import { + type MMParams, + solveAlertThresholds, + solveLiquidationThresholds, +} from "@hashpower/portfolio-margin"; +import { PredictiveIndex } from "./predictiveIndex.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: ParticipantSource; + 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: ParticipantSource, + 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; + } + + /** 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 + * 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"); + } + } +} + +/** + * 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 + * 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/predictiveIndex.ts b/keeper/src/predict/predictiveIndex.ts new file mode 100644 index 0000000..eb090a9 --- /dev/null +++ b/keeper/src/predict/predictiveIndex.ts @@ -0,0 +1,177 @@ +import type { Address } from "viem"; +import type { PriceThresholds } from "@hashpower/portfolio-margin"; + +/** + * 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; + } + + /** 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 — + * 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..d1137f2 --- /dev/null +++ b/keeper/src/predict/snapshot.ts @@ -0,0 +1,249 @@ +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 { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; +import type { AccountSnapshot, MMParams } from "@hashpower/portfolio-margin"; +import { PerpsPositionAbi } from "../venues/perpsPositionAbi.ts"; + +/** + * Read the engine-wide constants once. They only change on PME admin + * 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 { + // 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: [ + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "imSpotShock" as const, + }, + { + address: config.pme.address, + abi: PortfolioMarginEngineAbi, + functionName: "mmSpotShock" as const, + }, + { + address: collateralToken, + abi: erc20Abi, + 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, 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 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 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. + */ +export async function readAccountSnapshot( + chain: Chain, + config: Config, + user: Address, +): Promise { + const [ + balance, + perpPosition, + perpRisk, + perpOrderAggregate, + futuresRisk, + activeExpirationAts, + tradableExpirationAts, + ] = await chain.publicClient.multicall({ + contracts: [ + { + address: config.vault.address, + abi: CollateralVaultAbi, + functionName: "balanceOf" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: PerpsPositionAbi, + functionName: "getUserPosition" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getRiskView" as const, + args: [user] as const, + }, + { + address: config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "getOrderAggregate" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getRiskView" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates" as const, + args: [user] as const, + }, + { + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getExpirationDates" as const, + }, + ] as const, + allowFailure: false, + }); + + const expirationAts = activeExpirationAts as readonly bigint[]; + const orderExpirationAts = tradableExpirationAts as readonly bigint[]; + const futuresPositions: AccountSnapshot["futures"]["positions"] = []; + + type OrderAggregate = { + buyQty: bigint; + sellQty: bigint; + buyValue: bigint; + sellValue: bigint; + }; + let futuresOrderAggregate: OrderAggregate = { + buyQty: 0n, + sellQty: 0n, + buyValue: 0n, + sellValue: 0n, + }; + + if (expirationAts.length > 0 || orderExpirationAts.length > 0) { + const perExpiry = await chain.publicClient.multicall({ + contracts: [ + ...expirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition" as const, + args: [user, expirationAt] as const, + })), + ...expirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "settlementPrice" as const, + args: [expirationAt] as const, + })), + ...orderExpirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getOrderAggregateAtExpiration" as const, + args: [user, expirationAt] as const, + })), + ], + allowFailure: false, + }); + + for (let i = 0; i < expirationAts.length; i++) { + const pos = perExpiry[i] as { netQuantity: bigint; netEntryValue: bigint } | undefined; + const settlementPrice = perExpiry[expirationAts.length + i] as bigint | undefined; + const expirationAt = expirationAts[i]; + if (pos === undefined || expirationAt === undefined) continue; + if (pos.netQuantity === 0n) continue; + futuresPositions.push({ + expirationAt, + netQuantity: pos.netQuantity, + netEntryValue: pos.netEntryValue, + 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; + return { + user, + balance: balance as bigint, + perp: { + netQty: perpPosition.netQuantity, + entryPrice: + perpPosition.netQuantity === 0n + ? 0n + : (abs(perpPosition.netEntryValue) * 1_000_000n) / abs(perpPosition.netQuantity), + orders: restingOrders(perpRisk, perpOrderAggregate), + // PME uses `max(0, pendingFunding)` — only what the user owes. + fundingOwed: funding > 0n ? funding : 0n, + }, + futures: { + positions: futuresPositions, + orders: restingOrders(futuresRisk, futuresOrderAggregate), + }, + }; +} + +/** Pair a venue's risk deltas with its cached order aggregate. */ +function restingOrders( + risk: { buyOrderDelta: bigint; sellOrderDelta: bigint }, + aggregate: { buyValue: bigint; sellValue: bigint }, +): AccountSnapshot["perp"]["orders"] { + return { + buyDelta: risk.buyOrderDelta, + sellDelta: risk.sellOrderDelta, + buyValue: aggregate.buyValue, + sellValue: aggregate.sellValue, + }; +} + +function abs(value: bigint): bigint { + return value < 0n ? -value : value; +} 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 new file mode 100644 index 0000000..d326639 --- /dev/null +++ b/keeper/src/runtime/healthcheck.ts @@ -0,0 +1,300 @@ +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 { 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"; + +/** + * Health and metrics surface for the keeper. + * + * 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` + * 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 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; + 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: ParticipantSource, + executor: CoordinatorExecutor, + queue: CoordinatorQueue, + logger: pino.Logger, + predictor?: PredictiveCoordinator, + priceFeed?: PriceFeed, + futuresExpiryIndex?: FuturesExpiryIndex, + deliveryCoordinator?: DeliveryCoordinator, + ) { + this.config = config; + this.signerAddress = signerAddress; + this.tracker = tracker; + this.executor = executor; + this.queue = queue; + this.predictor = predictor; + this.priceFeed = priceFeed; + this.futuresExpiryIndex = futuresExpiryIndex; + this.deliveryCoordinator = deliveryCoordinator; + this.logger = logger.child({ component: "healthcheck" }); + } + + /** + * 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 { + version: this.config.version, + 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, + 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(); + 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(), + queueDepth: this.queue.size(), + 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, + 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, + }; + } + + 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 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, + 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()); + 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.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 + * 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; + 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; + } + // 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}`); + } + return `${lines.join("\n")}\n`; + } +} + +/** 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; +} + +const STARTUP_TIMEOUT_MS = 10 * 60 * 1000; + +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/scheduler.ts b/keeper/src/runtime/scheduler.ts new file mode 100644 index 0000000..3368015 --- /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 { ParticipantSource } from "../discovery/types.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: ParticipantSource; + private readonly queue: CoordinatorQueue; + private readonly executor: CoordinatorExecutor; + private readonly notifier: Notifier; + private readonly logger: pino.Logger; + + constructor( + chain: Chain, + config: Config, + tracker: ParticipantSource, + 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/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 new file mode 100644 index 0000000..2c74e5d --- /dev/null +++ b/keeper/src/tx/liquidate.ts @@ -0,0 +1,190 @@ +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"; +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 + * (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" + | "OverLiquidation" + | "OrderNotBelongToUser" + | "OrderNotBelongToParticipant" + | "PositionNotBelongToParticipant" + | "PositionNotExists"; + +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", + "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; + /** + * 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; +} + +/** + * 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, + 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 + // 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, ...formatGasCost(receipt, ethUsdFeed) }, + "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/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/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; +} diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts new file mode 100644 index 0000000..b5b7aba --- /dev/null +++ b/keeper/src/venues/futures.ts @@ -0,0 +1,286 @@ +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 { 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"; +import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; +import type { + LiquidateOrdersOutcome, + MarketId, + ReduceToTargetOutcome, + Venue, + VenueOrder, + 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 = [ + ...HashPowerFuturesAbi.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). + * + * 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; + + private readonly chain: Chain; + private readonly config: Config; + private readonly logger: pino.Logger; + private readonly ethUsdFeed: EthUsdFeed | undefined; + private mmParams: MMParams | undefined; + + constructor( + chain: Chain, + config: Config, + logger: pino.Logger, + ethUsdFeed?: EthUsdFeed, + ) { + this.chain = chain; + this.config = config; + this.logger = logger.child({ venue: "futures" }); + this.ethUsdFeed = ethUsdFeed; + } + + marketLabel(marketId: MarketId): string { + const expirationAt = marketIdToExpirationAt(marketId); + const iso = new Date(Number(expirationAt) * 1000).toISOString().slice(0, 10); + return `futures ${iso}`; + } + + async readOpenOrders(user: Address): Promise { + const orderIds = await this.readActiveOrderIds(user); + if (orderIds.length === 0) return []; + + const orders = await this.chain.publicClient.multicall({ + contracts: orderIds.map((id) => ({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getOrder" as const, + args: [id] as const, + })), + allowFailure: false, + }); + + return orderIds.map((id, i) => ({ + id, + marketId: expirationAtMarketId(orders[i].expirationAt), + })); + } + + async readPositions(user: Address): Promise { + const [expirationAts, marketPrice] = await Promise.all([ + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates", + args: [user], + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getMarketPrice", + }) as Promise, + ]); + + if (expirationAts.length === 0) return []; + + const positions = await this.chain.publicClient.multicall({ + contracts: expirationAts.map((expirationAt) => ({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition" as const, + args: [user, expirationAt] as const, + })), + allowFailure: false, + }); + + const out: VenuePosition[] = []; + for (let i = 0; i < expirationAts.length; i++) { + const expirationAt = expirationAts[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 avgEntry = abs(pos.netEntryValue) / absQty; + const notional = avgEntry * absQty; + + out.push({ + id: expirationAtMarketId(expirationAt), + marketId: expirationAtMarketId(expirationAt), + unrealizedLoss, + notional, + }); + } + return out; + } + + async liquidateOrders( + user: Address, + ids?: readonly Hex[], + ): Promise { + let targetIds = ids; + if (targetIds === undefined) { + targetIds = await this.readActiveOrderIds(user); + } + 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: FUTURES_LIQUIDATE_ORDERS_ABI, + functionName: "liquidateOrders", + args: [user, targetIds], + feeEventName: "OrderLiquidated", + ethUsdFeed: this.ethUsdFeed, + }); + + return "skipped" in result + ? { skipped: "notLiquidatable" } + : { feeEarned: result.feeEarned }; + } + + async reduceToTarget(user: Address): Promise { + 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: HashPowerFuturesAbi, + functionName: "getMarketPrice", + }) as Promise, + ]); + + // 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: 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 expirationAts = chunk.map((c) => c.expirationAt); + const closeQtys = chunk.map((c) => c.closeQty); + const contractsClosed = closeQtys.reduce((s, q) => s + q, 0n); + + this.logger.info( + { + user, + legsInChunk: chunk.length, + legsToClose: closes.length, + contractsClosed: contractsClosed.toString(), + ofExpiries: snapshot.futures.positions.length, + chunked: chunk.length < closes.length, + }, + "Futures reduceToTarget: closing worst-first expiry chunk", + ); + + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "liquidatePositions", + args: [user, expirationAts, closeQtys], + feeEventName: "PositionLiquidated", + mapSkip: (errorName) => { + if (errorName === "OrdersStillOpen") return "ordersStillOpen"; + return "notLiquidatable"; + }, + ethUsdFeed: this.ethUsdFeed, + }); + + return "skipped" in result + ? { skipped: result.skipped } + : { 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); + return this.mmParams; + } +} + +function abs(x: bigint): bigint { + return x < 0n ? -x : x; +} + +/** `bytes32(uint256(expirationAt))` — same encoding the indexer uses. */ +export function expirationAtMarketId(expirationAt: bigint): MarketId { + return pad(toHex(expirationAt), { size: 32 }); +} + +/** Inverse of `expirationAtMarketId` — used by the planner / labels. */ +export function marketIdToExpirationAt(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..0689acb --- /dev/null +++ b/keeper/src/venues/perps.ts @@ -0,0 +1,251 @@ +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 { 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, + ReduceToTargetOutcome, + Venue, + VenueOrder, + 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 + * 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; + private readonly ethUsdFeed: EthUsdFeed | undefined; + private mmParams: MMParams | undefined; + + 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 { + 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. 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: PerpsPositionAbi, + 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: 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; + + this.logger.debug( + { + user, + isLong, + qty: position.netQuantity, + marketPrice, + unrealizedLoss, + notional, + }, + "perps position read", + ); + + return [ + { + id: perpsPositionId(user), + marketId: PERPS_MARKET_ID, + unrealizedLoss, + notional, + }, + ]; + } + + /** + * 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, + ids?: readonly Hex[], + ): Promise { + 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) { + return { skipped: "notLiquidatable" }; + } + + const result = await sendLiquidate({ + chain: this.chain, + config: this.config, + logger: this.logger, + address: this.config.perps.address, + abi: PERPS_LIQUIDATE_ORDERS_ABI, + functionName: "liquidateOrders", + args: [user, targetIds], + feeEventName: "OrderLiquidated", + ethUsdFeed: this.ethUsdFeed, + }); + + return "skipped" in result + ? { skipped: "notLiquidatable" } + : { feeEarned: result.feeEarned }; + } + + 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, + logger: this.logger, + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "liquidatePosition", + args: [user, closeQty], + feeEventName: "PositionLiquidated", + mapSkip: (errorName) => { + if (errorName === "OrdersStillOpen") return "ordersStillOpen"; + // `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"; + }, + ethUsdFeed: this.ethUsdFeed, + }); + + return "skipped" in result + ? { skipped: result.skipped } + : { 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; + } +} + +/** 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/perpsPositionAbi.ts b/keeper/src/venues/perpsPositionAbi.ts new file mode 100644 index 0000000..6baf9bd --- /dev/null +++ b/keeper/src/venues/perpsPositionAbi.ts @@ -0,0 +1,19 @@ +/** Exact local fragment while the pinned perps ABI still exposes the legacy position tuple. */ +export const PerpsPositionAbi = [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, +] as const; diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts new file mode 100644 index 0000000..2bd1d8a --- /dev/null +++ b/keeper/src/venues/types.ts @@ -0,0 +1,106 @@ +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(expirationAt)) + * - 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" }; + +/** + * 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 / 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 + * (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(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 discovers resting ids first (perps: + * `getUserOrders`; futures: `getExpirationDates` + per-expiry order ids). + */ + liquidateOrders(user: Address, ids?: readonly Hex[]): Promise; + + /** + * 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: 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, expirationAts[], + * closeQtys[])`, perps `liquidatePosition(user, closeQty)`. + * Oversize partials revert `OverLiquidation` (re-size off-chain). + * + * 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. + */ + reduceToTarget(user: Address): 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..f351ae2 --- /dev/null +++ b/keeper/tests/coordinator/planner.test.ts @@ -0,0 +1,366 @@ +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, + ReduceToTargetOutcome, + 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; + reduceCalls: number; +} + +function makeFakeVenue(name: Venue["name"], opts: { + marketId: Hex; + ordersByCall?: VenueOrder[][]; + positionsByCall?: VenuePosition[][]; + ordersOutcomeByCall?: LiquidateOrdersOutcome[]; + reduceOutcomeByCall?: ReduceToTargetOutcome[]; +}): FakeVenue { + let openOrdersCall = 0; + let positionsCall = 0; + let liqOrdersCall = 0; + let reduceCall = 0; + const venue: FakeVenue = { + name, + ordersCalls: 0, + reduceCalls: 0, + marketLabel: () => `${name}-market`, + async readOpenOrders(_user) { + const list = opts.ordersByCall?.[openOrdersCall++] ?? []; + return list; + }, + async readPositions(_user) { + // 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) { + venue.ordersCalls++; + const out = opts.ordersOutcomeByCall?.[liqOrdersCall++]; + 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 + 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.reduceCalls, 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.reduceCalls, 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("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 + { 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 reduced first, and one batched call heals the account. + positionsByCall: [[{ id: heavyPosId, marketId: MARKET_FUT_A, unrealizedLoss: 500n, notional: 2000n }]], + reduceOutcomeByCall: [{ feeEarned: 12n, positionsClosed: 3 }], + }); + + const planner = new Planner(chain, makeConfigStub(), [perps, futures], silentLogger); + const outcome = await planner.run(USER); + + 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, 3, "batched close reports its lot count"); + assert.equal(outcome.feeEarned, 12n); + } else { + assert.fail(`expected liquidated, got ${outcome.kind}`); + } + }); + + 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 + { 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 perps = makeFakeVenue("perps", { + marketId: MARKET_PERPS, + ordersByCall: [[]], + positionsByCall: [[{ id: smallId, marketId: MARKET_PERPS, unrealizedLoss: 100n, notional: 500n }]], + }); + 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.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 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 }, + { 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, empty), then race-injected order for the replay. + 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 }], + ], + reduceOutcomeByCall: [ + { skipped: "ordersStillOpen" }, + { feeEarned: 7n, positionsClosed: 1 }, + ], + }); + const planner = new Planner(chain, makeConfigStub(), [venue], silentLogger); + const outcome = await planner.run(USER); + + 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); + 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: nothingToClose` when every venue can't size a close", async () => { + const chain = makeChainStub([ + { 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 }]], + 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, "nothingToClose"); + } + assert.equal(venue.reduceCalls, 1, "parked after one nothingToClose"); + }); + + 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/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts new file mode 100644 index 0000000..65247da --- /dev/null +++ b/keeper/tests/delivery/coordinator.test.ts @@ -0,0 +1,390 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + BaseError, + ContractFunctionRevertedError, + type Address, + type Hex, + 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"; +import type { + FuturesExpiryIndex, + PositionListener, +} from "../../src/discovery/futuresExpiryIndex.ts"; + +const FUTURES = "0x000000000000000000000000000000000000F00d" as Address; +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, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as pino.Logger; + +function makeConfig(overrides: Partial = {}): Config { + return { + futures: { address: FUTURES }, + keeper: { dryRun: false }, + coordinator: { confirmationBlocks: 1 }, + delivery: { + enabled: true, + sweepIntervalMs: 1_000_000, + settleDelayMs: 0, + bootstrapUsers: [], + maxBatchSize: 50, + ...overrides, + }, + } as Config; +} + +interface MatchedLog { + args: { + maker: Address; + taker: Address; + expirationAt: bigint; + makerNetQtyAfter: bigint; + takerNetQtyAfter: bigint; + }; +} + +interface SettledLog { + args: { user: Address; expirationAt: bigint }; +} + +function matchedLog( + maker: Address, + taker: Address, + expirationAt: bigint, + makerQty: bigint, + takerQty: bigint, +): MatchedLog { + return { + args: { + maker, + taker, + expirationAt, + makerNetQtyAfter: makerQty, + takerNetQtyAfter: takerQty, + }, + }; +} + +function settledLog(user: Address, expirationAt: bigint): SettledLog { + return { args: { user, expirationAt } }; +} + +interface ChainStubOptions { + blockNumber?: bigint; + blockTimestamp?: bigint; + simulate?: (args: readonly unknown[]) => { request: { ok: true } } | { error: unknown }; + writeHash?: Hex; + receipt?: TransactionReceipt; + watchers?: { + orderMatched?: (logs: readonly MatchedLog[]) => void; + positionSettled?: (logs: readonly SettledLog[]) => void; + }; + history?: { + OrderMatched?: MatchedLog[]; + PositionSettled?: SettledLog[]; + }; + activeDatesByUser?: Record; + positionsByUserDate?: Record; + readContractError?: (functionName: string) => Error | undefined; + writes?: Array<{ functionName: string; args: readonly unknown[] }>; +} + +function posKey(user: Address, expirationAt: bigint): string { + return `${user.toLowerCase()}:${expirationAt}`; +} + +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: { + 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 === "getActiveExpirationDates") { + const user = (args?.[0] as Address).toLowerCase(); + return opts.activeDatesByUser?.[user] ?? []; + } + throw new Error(`unexpected readContract: ${functionName}`); + }, + multicall: async ({ + contracts, + }: { + contracts: readonly { functionName: string; args?: readonly unknown[] }[]; + }) => { + return contracts.map((c) => { + if (c.functionName === "getUserPosition") { + const user = (c.args?.[0] as Address).toLowerCase(); + const expirationAt = c.args?.[1] as bigint; + const key = `${user}:${expirationAt}`; + return ( + opts.positionsByUserDate?.[key] ?? { netQuantity: 0n, netEntryValue: 0n } + ); + } + if (c.functionName === "getActiveExpirationDates") { + const user = (c.args?.[0] as Address).toLowerCase(); + return opts.activeDatesByUser?.[user] ?? []; + } + throw new Error(`unexpected multicall: ${c.functionName}`); + }); + }, + getContractEvents: async ({ eventName }: { eventName: string }) => { + if (eventName === "OrderMatched") return opts.history?.OrderMatched ?? []; + if (eventName === "PositionSettled") return opts.history?.PositionSettled ?? []; + return []; + }, + watchContractEvent: ({ + eventName, + onLogs, + }: { + eventName: string; + onLogs: (logs: readonly unknown[]) => void; + }) => { + 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 ({ + functionName, + args, + }: { + functionName: string; + args: readonly unknown[]; + }) => { + opts.writes?.push({ functionName, args }); + return writeHash; + }, + }, + } as unknown as Chain; +} + +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("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 }); + const coord = new DeliveryCoordinator(chain, makeConfig(), silentLogger); + await coord.start(); + + 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.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("drops a user when OrderMatched reports netQtyAfter=0", async () => { + const watchers: ChainStubOptions["watchers"] = {}; + const chain = makeChain({ watchers }); + 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(); + }); + + 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("delivery/coordinator: bootstrap + settle", () => { + it("bootstrapFromUsers indexes active aggregates", async () => { + const chain = makeChain({ + activeDatesByUser: { + [USER_A.toLowerCase()]: [DELIVERY_A, DELIVERY_B], + }, + 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 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("indexUserPositions swallows getActiveExpirationDates RPC errors", async () => { + const chain = makeChain({ + readContractError: (fn) => + 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, 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, + simulate: (args) => { + simulated.push([...args]); + return { request: { ok: true } }; + }, + activeDatesByUser: { [USER_A.toLowerCase()]: [DELIVERY_A] }, + 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]); + 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(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); + }); + + it("backfill replays OrderMatched then PositionSettled", async () => { + const chain = makeChain({ + blockNumber: 50n, + history: { + OrderMatched: [matchedLog(USER_A, USER_B, DELIVERY_A, -1n, 1n)], + PositionSettled: [settledLog(USER_A, DELIVERY_A)], + }, + blockTimestamp: 1n, + }); + 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("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/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 new file mode 100644 index 0000000..3cead10 --- /dev/null +++ b/keeper/tests/discovery/tracker.test.ts @@ -0,0 +1,346 @@ +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 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, + // 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) } }, + ], + }, + }), + config, + silentLogger, + ); + await t.backfill(0n, 500n); + assert.equal(t.size(), 6); + }); + + 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); + // 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( + 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 >= 4, "all four 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/integration/artifacts.ts b/keeper/tests/integration/artifacts.ts new file mode 100644 index 0000000..97de775 --- /dev/null +++ b/keeper/tests/integration/artifacts.ts @@ -0,0 +1,138 @@ +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/HashPowerFutures", + "HashPowerFutures", + ), +} 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..047de12 --- /dev/null +++ b/keeper/tests/integration/buildKeeper.ts @@ -0,0 +1,327 @@ +import pino from "pino"; +import pretty from "pino-pretty"; +import { + createPublicClient, + createWalletClient, + type Account, + type Address, + type PublicClient, + type Transport, + 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 { 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"; +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 { 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"; + +/** + * 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; + futuresExpiryIndex: FuturesExpiryIndex; + participants: ParticipantSource; + queue: CoordinatorQueue; + planner: Planner; + executor: CoordinatorExecutor; + scheduler: Scheduler; + notifier: Notifier; + priceFeed: PriceFeed; + predictor: PredictiveCoordinator; + /** + * Only present when `BuildKeeperOverrides.delivery` is set. Tests that + * exercise the delivery module pass `delivery: true`. Settlement via + * `settlePosition` is permissionless, so the keeper signer needs no + * special role. + */ + delivery?: DeliveryCoordinator; + 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}`; + /** + * 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; + /** + * 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 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). + */ + deliveryMaxBatchSize?: number; +} + +const LIQUIDATOR_PK = HARDHAT_PRIVATE_KEYS[3]; + +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.transport, + overrides.liquidatorPrivateKey ?? LIQUIDATOR_PK, + stack.addresses.multicall3, + ); + + const log = + logger ?? + pino(pretty({ sync: true, colorize: true, minimumLevel: "fatal" })); + + const venues: Venue[] = [ + new PerpsVenue(chain, config, log), + new FuturesVenue(chain, config, log), + ]; + + 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, + participants, + 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, + log, + stack.config.tokenDecimals, + ); + const predictor = new PredictiveCoordinator( + chain, + config, + participants, + queue, + executor, + priceFeed, + log, + notifier, + ); + + // Newly-tracked users wake idle workers — same edge `keeper/src/index.ts` + // wires in production. + 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, + undefined, + futuresExpiryIndex, + ) + : undefined; + + let started = false; + return { + config, + chain, + tracker, + futuresExpiryIndex, + participants, + queue, + planner, + executor, + scheduler, + notifier, + priceFeed, + predictor, + delivery, + async start() { + if (started) return; + started = true; + 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 + // `scheduler.runSweep()` to avoid timer races against `evm_revert`. + }, + async stop() { + if (!started) return; + started = false; + scheduler.stop(); + predictor.stop(); + priceFeed.stop(); + delivery?.stop(); + futuresExpiryIndex.stop(); + await executor.stop(); + tracker.stop(); + }, + }; +} + +function buildConfig( + stack: DeployedStack, + overrides: BuildKeeperOverrides, +): Config { + return { + version: "test", + 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, maxLotsPerLiquidationTx: 50 }, + 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", + // 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, + }, + delivery: { + enabled: overrides.delivery === 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, + }, + }; +} + +/** + * 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( + transport: Transport, + privateKey: `0x${string}`, + multicall3Address: `0x${string}`, +): Chain { + const account: Account = privateKeyToAccount(privateKey); + const chainWithMulticall = { + ...hardhat, + contracts: { + ...hardhat.contracts, + 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, + 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..d6a25ea --- /dev/null +++ b/keeper/tests/integration/deployStack.ts @@ -0,0 +1,500 @@ +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 + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", // #5 dave (second test trader) +] as const satisfies readonly Hex[]; + +export interface Wallet { + account: Account; + client: WalletClient; +} + +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; + alice: Wallet; + bob: Wallet; + liquidator: Wallet; + validator: Wallet; + /** Spare trader for multi-account scenarios. */ + dave: 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; + /** + * 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` 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; + minimumPriceIncrement: bigint; + quantityDecimals: number; + perpsLiquidationFeeBps: bigint; + perpsTakerFeeBps: bigint; + perpsMakerFeeBps: bigint; + futuresMakerFeeBps: bigint; + futuresTakerFeeBps: bigint; + futuresLiquidationFeeBps: bigint; + futuresFirstExpirationAt: bigint; + insuranceFund: bigint; + initialUserBalance: bigint; + }; +} + +const TOKEN_DECIMALS = 6; +const ORACLE_DECIMALS = 6; +const QUANTITY_DECIMALS = 6; + +/** + * Per-contract mark at deploy time. Positions and orders are denominated in this + * (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 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); + +const MIN_PRICE_INCREMENT = parseUnits("0.01", 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; +// 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; +const FUTURES_FUTURE_DELIVERY_DATES_COUNT = 10; +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 + // 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 + // 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; vault is an immutable constructor arg) ───────── + const perpsArt = artifacts.perps(); + const perpsImpl = await deploy(publicClient, owner.client, perpsArt, [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(); + const futuresImpl = await deploy(publicClient, owner.client, futuresArt, [ + 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 firstExpirationAt = + latestBlock.timestamp + BigInt(FUTURES_EXPIRATION_INTERVAL_DAYS * 24 * 3600); + // initialize(hashrateOracle, liquidationMarginPercent, + // futureExpirationDatesCount, firstFutureExpirationDate) + const futures = await deployProxy( + publicClient, + owner.client, + futuresImpl, + futuresArt.abi, + "initialize", + [ + hashpriceOracle, + FUTURES_LIQUIDATION_MARGIN_PCT, + FUTURES_FUTURE_DELIVERY_DATES_COUNT, + firstExpirationAt, + ], + ); + + // ── 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", + [], + ); + + // ── Wire PME ↔ venues ↔ vault ───────────────────────────────────────── + // 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( + 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 (bps setters). + await write( + publicClient, + owner.client, + perps, + perpsArt.abi, + "setPortfolioMargin", + [pme], + ); + 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( + publicClient, + owner.client, + perps, + perpsArt.abi, + "setLiquidationFeeBps", + [Number(PERPS_LIQUIDATION_FEE_BPS)], + ); + + // Futures -> PME + fees (bps setters). + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "setPortfolioMargin", + [pme], + ); + await write( + publicClient, + owner.client, + futures, + futuresArt.abi, + "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, + "setLiquidationFeeBps", + [Number(FUTURES_LIQUIDATION_FEE_BPS)], + ); + + // ── Fund & approve test wallets ─────────────────────────────────────── + 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, dave]) { + 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, + transport, + rpcUrl, + accounts: { owner, alice, bob, liquidator, validator, dave }, + 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, + initialMarketPrice: INITIAL_MARKET_PRICE, + initialBtcUsdc: INITIAL_BTC_USDC, + minimumPriceIncrement: MIN_PRICE_INCREMENT, + quantityDecimals: QUANTITY_DECIMALS, + perpsLiquidationFeeBps: PERPS_LIQUIDATION_FEE_BPS, + perpsTakerFeeBps: PERPS_TAKER_FEE_BPS, + perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, + futuresMakerFeeBps: FUTURES_MAKER_FEE_BPS, + futuresTakerFeeBps: FUTURES_TAKER_FEE_BPS, + futuresLiquidationFeeBps: FUTURES_LIQUIDATION_FEE_BPS, + futuresFirstExpirationAt: firstExpirationAt, + 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) { + 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/hardhat.config.ts b/keeper/tests/integration/hardhat.config.ts new file mode 100644 index 0000000..0f3a8cb --- /dev/null +++ b/keeper/tests/integration/hardhat.config.ts @@ -0,0 +1,15 @@ +/** + * Keeper integration-node configuration only. + * + * Sibling implementations can exceed EIP-170 while under active development; + * the integration suite exercises their behavior, not deployability. + */ +export default { + networks: { + hardhat: { + type: "edr-simulated", + chainType: "l1", + allowUnlimitedContractSize: true, + }, + }, +}; diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts new file mode 100644 index 0000000..580b560 --- /dev/null +++ b/keeper/tests/integration/helpers.ts @@ -0,0 +1,505 @@ +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"; +import { PerpsPositionAbi } from "../../src/venues/perpsPositionAbi.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 +// ───────────────────────────────────────────────────────────────────────── + +/** + * 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 + * empty user set. + */ +export async function discoverUser( + keeper: KeeperHarness, + user: Address, + timeoutMs = 10_000, +): Promise { + await waitFor(() => keeper.participants.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; + netEntryValue: bigint; +} + +export async function readPerpsPosition( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.perps, + abi: PerpsPositionAbi, + 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[]; +} + +/** Active delivery dates for a user's unilateral futures aggregates. */ +export async function readFuturesActiveDates( + stack: DeployedStack, + user: Address, +): Promise { + return (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getActiveExpirationDates", + args: [user], + })) 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 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 expirationAt values (from `readFuturesPositionIds`) back + * to bigint expiries. Kept for multi-expiry balancing tests. + */ +export async function readFuturesLotExpiries( + _stack: DeployedStack, + ids: readonly Hex[], +): Promise> { + return new Map(ids.map((id) => [id, BigInt(id)] as const)); +} + +export async function readFuturesOrderIds( + stack: DeployedStack, + user: Address, +): Promise { + const expirationAts = (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getExpirationDates", + })) as readonly bigint[]; + if (expirationAts.length === 0) return []; + + const perExpiry = await Promise.all( + expirationAts.map( + (expirationAt) => + stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getUserOrdersAtExpiration", + args: [user, expirationAt], + }) as Promise, + ), + ); + + const orderIds: Hex[] = []; + for (const ids of perExpiry) { + for (const id of ids) orderIds.push(id); + } + return orderIds; +} + +/** Resolves to true once `user` is flat on perps. */ +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); +} + +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 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). + */ +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 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 + * 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.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, + user: Address, +): Promise { + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses.futures, + abi: stack.abis.futures, + eventName: "PositionLiquidated", + args: { user }, + fromBlock: 0n, + }); + const blocks: bigint[] = []; + for (const log of logs) { + if (log.blockNumber !== null) blocks.push(log.blockNumber); + } + 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, + expirationAt: bigint, +): Promise { + const pos = (await stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getUserPosition", + args: [user, expirationAt], + })) 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. + * 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, + 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); +} + +// ───────────────────────────────────────────────────────────────────────── +// 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). +// +// 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", "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", { user: u }); + +/** + * 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, + expirationAtHex: Hex, +): Promise { + const expirationAt = BigInt(expirationAtHex); + const logs = await stack.publicClient.getContractEvents({ + address: stack.addresses.futures, + abi: stack.abis.futures, + eventName: "PositionSettled", + args: { user, expirationAt }, + 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", + 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 +// ───────────────────────────────────────────────────────────────────────── + +/** + * 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..898fd49 --- /dev/null +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -0,0 +1,1219 @@ +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, + perpsShortCrashFixtureBuilder, + perpsOrdersAndPositionFixtureBuilder, + twoUnderwaterUsersFixtureBuilder, + futuresLongCrashFixtureBuilder, + futuresOrdersAndPositionFixtureBuilder, + multiFuturesFixtureBuilder, + futuresPartialCrashFixtureBuilder, + futuresMultiExpiryPartialCrashFixtureBuilder, + perpsPartialCrashFixtureBuilder, + crossVenuePerpsDominantFixtureBuilder, + crossVenueFuturesDominantFixtureBuilder, + crossVenueOrdersAndPositionsFixtureBuilder, + crossVenuePartialCrashFixtureBuilder, + crossVenueBothLegsCrashFixtureBuilder, +} from "./scenarios.ts"; +import { + discoverUser, + discoverAndIndex, + runOneSweep, + readPerpsOrderIds, + readFuturesOrderIds, + readFuturesPositionIds, + readPerpsPositionLiquidationBlock, + readFuturesPositionLiquidationBlock, + readPerpsOrderLiquidationBlock, + readFuturesOrderLiquidationBlock, + readLotClosedBlock, + readPerpsPosition, + readAccountMargins, + expectPerpsClosed, + expectFuturesClosed, + expectNoOpenOrders, + expectHealthy, + expectReducedToImBuffer, + readFuturesLotLiquidatedBlocks, + readFuturesNetQuantity, + readFuturesClosedQuantity, + assertSingleBlock, + isCriticalAlert, + waitFor, +} from "./helpers.ts"; +import { HARDHAT_PRIVATE_KEYS } from "./deployStack.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 perpsShortCrashFixture: ReturnType; +let perpsOrdersAndPositionFixture: ReturnType; +let twoUnderwaterUsersFixture: ReturnType; +let futuresLongCrashFixture: 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 () => { + 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); + perpsShortCrashFixture = perpsShortCrashFixtureBuilder(node.rpcUrl); + perpsOrdersAndPositionFixture = perpsOrdersAndPositionFixtureBuilder(node.rpcUrl); + twoUnderwaterUsersFixture = twoUnderwaterUsersFixtureBuilder(node.rpcUrl); + 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 }, +); + +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); + }, + ); + + 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 `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(); + + 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}`, + ); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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. 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(); + + 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 (`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); + 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); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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 qty into the [MM, IM] band", + { timeout: 60_000 }, + 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, 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 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, expirationAt), + BigInt(ctx.aliceFuturesQty), + "precondition: aggregate net qty equals matched contracts", + ); + + await ctx.makeLiquidatable(); + await runOneSweep(keeper, alice); + + await expectReducedToImBuffer(ctx, alice); + + const netAfter = await readFuturesNetQuantity(ctx, alice, expirationAt); + assert.ok(netAfter > 0n, `expected partial close (qty remaining), got ${netAfter}`); + assert.ok( + netAfter < BigInt(ctx.aliceFuturesQty), + `expected some contracts closed, before=${ctx.aliceFuturesQty} after=${netAfter}`, + ); + + const closed = await readFuturesClosedQuantity(ctx, alice); + assert.equal(closed, BigInt(ctx.aliceFuturesQty) - netAfter); + + const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); + assert.ok(liqBlocks.length >= 1, "expected PositionLiquidated"); + 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-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 datesBefore = await readFuturesPositionIds(ctx, alice); + assert.equal( + datesBefore.length, + ctx.expirationAts.length, + "precondition: one aggregate per delivery date", + ); + const [firstDelivery, secondDelivery] = ctx.expirationAts; + 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); + + await expectReducedToImBuffer(ctx, alice); + + 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( + closedA >= 1n && closedB >= 1n, + `expected the close to span BOTH expirations, got first=${closedA} second=${closedB}`, + ); + const skew = closedA > closedB ? closedA - closedB : closedB - closedA; + assert.ok( + skew <= 1n, + `expected a balanced split across expirations (skew <= 1), got first=${closedA} second=${closedB}`, + ); + + const liqBlocks = await readFuturesLotLiquidatedBlocks(ctx, alice); + assert.ok(liqBlocks.length >= 1, "expected PositionLiquidated"); + 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 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 + // 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, 1, "precondition: one futures aggregate"); + assert.equal( + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), + BigInt(ctx.aliceFuturesQty), + ); + + 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}`, + ); + assert.equal( + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), + BigInt(ctx.aliceFuturesQty), + "futures net qty unchanged", + ); + }, + ); + + 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 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 + // 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 11 perps"); + const futuresBefore = await readFuturesPositionIds(ctx, alice); + assert.equal(futuresBefore.length, 1, "precondition: one futures aggregate"); + assert.equal( + await readFuturesNetQuantity(ctx, alice, ctx.config.futuresFirstExpirationAt), + BigInt(ctx.aliceFuturesQty), + "precondition: alice holds 12 futures contracts", + ); + + 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 PositionLiquidated 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 +// ───────────────────────────────────────────────────────────────────────── + +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(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); + }, + ); + + 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 ($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); + 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) + 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); + 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}}`, + ); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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); + }, + ); + + 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"); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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(); + } + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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. (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 `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); + keeper = buildKeeper(ctx, { + liquidatorPrivateKey: HARDHAT_PRIVATE_KEYS[4], // validator (parity; not required) + 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); + // 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.futuresFirstExpirationAt), + BigInt(ctx.aliceFuturesQty), + ); + + // 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.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 + // `block.timestamp >= position.expirationAt`, and `block.timestamp` is + // only advanced once a block is mined at the new clock. + 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 + // `_getHashpriceUsd` doesn't revert `OracleStale` inside + // `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(); + + // End state: every position is gone from chain storage, each emitted + // 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 `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(alice, BigInt(id))), + 10_000, + ); + + const settledBlocks: bigint[] = []; + for (const id of positionsBefore) { + const settledBlock = await readLotClosedBlock(ctx, alice, id); + assert.ok( + settledBlock !== null, + `expected a PositionSettled event for ${alice} @ ${id}`, + ); + settledBlocks.push(settledBlock); + } + // Single aggregate → one settle; still assert one block (multicall path). + 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 `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. + // + // 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 expirationAt *before* the keeper boots, so the live + // subscription would miss the (long-past) OrderMatched event. + 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. + 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 readLotClosedBlock(ctx, alice, 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 + // ``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. + 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(alice, BigInt(id)), `bootstrap should index position ${id}`); + } + + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 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 readLotClosedBlock(ctx, alice, id)) !== null, + `position ${id} should be settled via view-based bootstrap`, + ); + assert.equal(keeper.delivery.has(alice, BigInt(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 expirationAt before boot — same shape as the production + // outage where the keeper has been down/blind during the delivery + // window. + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 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 readLotClosedBlock(ctx, alice, id)) !== null, + `manually-seeded position ${id} should be settled`, + ); + } + }, + ); + + it( + "settles with a non-validator signer (settlePosition is permissionless)", + { timeout: 60_000 }, + async () => { + // 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); + keeper = buildKeeper(ctx, { + // Note: no `liquidatorPrivateKey` override → default account #3. + 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, "fixture should have created positions"); + + await keeper.delivery.backfill(0n, 10_000n); + + const expirationAt = ctx.config.futuresFirstExpirationAt; + await testClient.setNextBlockTimestamp({ timestamp: expirationAt + 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 readLotClosedBlock(ctx, alice, id)) !== null, + `position ${id} should be settled by a permissionless (non-validator) signer`, + ); + } + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// 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/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..b0df3e7 --- /dev/null +++ b/keeper/tests/integration/nodeProcess.ts @@ -0,0 +1,158 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { resolve } from "node:path"; +import { createPublicClient, http } from "viem"; + +/** + * 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 + * 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 installation should run + * the integration config. 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 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", "--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 + // 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..f3c12c0 --- /dev/null +++ b/keeper/tests/integration/scenarios.ts @@ -0,0 +1,1086 @@ +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. + * + * 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 { + /** 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). 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. */ + deposit(userAddr: Address, amount: bigint): Promise; + /** + * 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(marketPrice: bigint): Promise; + pumpOracles(marketPrice: 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 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. */ + 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; + expirationAts: readonly bigint[]; + 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 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; + /** The two delivery dates Alice holds lots on. */ + expirationAts: 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. */ + 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; +} + +/** + * 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 +// ───────────────────────────────────────────────────────────────────────── + +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, marketPrice), + deposit: (user, amount) => depositTo(stack, user, amount), + crashOracles: async (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, marketPrice); + const movedBtc = (stack.config.initialBtcUsdc * 11n) / 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.initialMarketPrice, + quantity: aliceQty, + }); + + return { + ...base, + aliceDeposit, + aliceQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + }; + }; +} + +/** + * 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.initialMarketPrice, + 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.initialMarketPrice, + -(aliceQty + daveQty), + ); + await placePerpsOrder(base, base.accounts.alice, base.config.initialMarketPrice, aliceQty); + await placePerpsOrder(base, base.accounts.dave, base.config.initialMarketPrice, 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 + * 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.initialMarketPrice, + 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. 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("40", 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.initialMarketPrice, + expirationAt: base.config.futuresFirstExpirationAt, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), + }; + }; +} + +/** + * 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, ids)` on futures → 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("40", 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.initialMarketPrice, + expirationAt: base.config.futuresFirstExpirationAt, + quantity: aliceFuturesQty, + }); + + // 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.tokenDecimals); + await placeFuturesOrder( + base, + base.accounts.alice, + restingPrice, + base.config.futuresFirstExpirationAt, + 1, + ); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + restingOrderCount: 1, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), + }; + }; +} + +/** + * 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("40", base.config.tokenDecimals); + const bobDeposit = parseUnits("3000", base.config.tokenDecimals); + const firstExpirationAt = base.config.futuresFirstExpirationAt; + // Must match on-chain Futures.EXPIRATION_INTERVAL_DAYS (= 30). + 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 expirationAt of [firstExpirationAt, secondExpirationAt]) { + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialMarketPrice, + expirationAt, + quantity: 6, + }); + } + + return { + ...base, + aliceDeposit, + expirationAts: [firstExpirationAt, secondExpirationAt] as const, + makeLiquidatable: () => + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), + }; + }; +} + +/** + * 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); + // 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 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); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: entryMark, + expirationAt: base.config.futuresFirstExpirationAt, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + aliceFuturesQty, + // 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("30", base.config.tokenDecimals)), + }; + }; +} + +/** + * Alice holds 6 long futures lots on EACH of two delivery dates (12 total), + * 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); + // 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 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. + await setFuturesTakerFee(base, 0n); + + await base.deposit(base.accounts.alice.account.address, aliceDeposit); + await base.deposit(base.accounts.bob.account.address, bobDeposit); + + for (const expirationAt of [firstExpirationAt, secondExpirationAt]) { + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: entryMark, + expirationAt, + quantity: perExpiryQty, + }); + } + + return { + ...base, + aliceDeposit, + expirationAts: [firstExpirationAt, secondExpirationAt] as const, + perExpiryQty, + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), + }; + }; +} + +/** + * 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.initialMarketPrice, + 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 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, 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 + * 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("53", 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.initialMarketPrice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialMarketPrice, + expirationAt: base.config.futuresFirstExpirationAt, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => + base.crashOracles(parseUnits("3.00", base.config.tokenDecimals)), + }; + }; +} + +/** + * 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). + * + * 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 + * - $235 deposit (−$1 perps taker fee ⇒ $234 balance) ⇒ underwater by ~$30.50 + * (substantial). + * + * 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). + */ +export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { + return async (): Promise => { + const base = await baseFixture(rpcUrl); + // Stage entry at a $40 mark (see `futuresPartialCrashFixtureBuilder`). + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("235", base.config.tokenDecimals); + const bobDeposit = parseUnits("5000", base.config.tokenDecimals); + const alicePerpsQty = parseUnits("11", base.config.quantityDecimals); + const aliceFuturesQty = 12; + + 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: entryMark, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: entryMark, + expirationAt: base.config.futuresFirstExpirationAt, + quantity: aliceFuturesQty, + }); + + return { + ...base, + aliceDeposit, + alicePerpsQty, + aliceFuturesQty, + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), + }; + }; +} + +/** + * Alice holds simultaneous perps + futures longs. A single oracle move + * puts both legs underwater at once, exercising the planner's coordinated + * cross-venue ranking. + * + * Two parameterised variants are exposed via dedicated builders: + * + * - `crossVenuePerpsDominantFixtureBuilder` — perps `unrealizedLoss` + * dominates futures (ratio ≈ 100:1). The planner should liquidate + * perps first, then futures. + * - `crossVenueFuturesDominantFixtureBuilder` — futures dominates perps + * (ratio ≈ 12:1). 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.initialMarketPrice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialMarketPrice, + expirationAt: base.config.futuresFirstExpirationAt, + 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 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 => { + const base = await baseFixture(rpcUrl); + 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); + // 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; + + 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.initialMarketPrice, + quantity: alicePerpsQty, + }); + await matchFuturesTrade(base, { + buyer: base.accounts.alice, + seller: base.accounts.bob, + price: base.config.initialMarketPrice, + expirationAt: base.config.futuresFirstExpirationAt, + 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.futuresFirstExpirationAt, + 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 ($50.40 loss, duration-free: 12 · ($4.21 − $0.01 mark)). + * The planner must liquidate futures first. + * + * 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 => { + const base = await baseFixture(rpcUrl); + return crossVenueFixtureBody(base, { + aliceDeposit: parseUnits("40", base.config.tokenDecimals), + bobDeposit: parseUnits("5000", base.config.tokenDecimals), + alicePerpsQty: parseUnits("1", base.config.quantityDecimals), + aliceFuturesQty: 12, + }); + }; +} + +// ───────────────────────────────────────────────────────────────────────── +// 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; + 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.expirationAt, -t.quantity); + await placeFuturesOrder(base, t.buyer, t.price, t.expirationAt, 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, GTC], + chain: hardhat, + account: wallet.account, + }); + await base.publicClient.waitForTransactionReceipt({ hash }); +} + +async function placeFuturesOrder( + base: BaseFixture, + wallet: Wallet, + price: bigint, + expirationAt: bigint, + qty: number, +): Promise { + // 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), GTC], + chain: hardhat, + account: wallet.account, + }); + await base.publicClient.waitForTransactionReceipt({ hash }); +} + +/** 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: "setTakerFeeBps", + args: [Number(feeBps)], + 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, + 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; + } +} diff --git a/keeper/tests/oracle/ethUsdFeed.test.ts b/keeper/tests/oracle/ethUsdFeed.test.ts new file mode 100644 index 0000000..cad0a4c --- /dev/null +++ b/keeper/tests/oracle/ethUsdFeed.test.ts @@ -0,0 +1,207 @@ +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(); + chain.publicClient.readContract = async ({ functionName }) => { + 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(); + }); + + 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/oracle/priceFeed.test.ts b/keeper/tests/oracle/priceFeed.test.ts new file mode 100644 index 0000000..c3dce82 --- /dev/null +++ b/keeper/tests/oracle/priceFeed.test.ts @@ -0,0 +1,161 @@ +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; +const PERPS = "0x000000000000000000000000000000000000aa03" as Address; + +function makeConfig(): Config { + return { + oracle: { + hashpriceUsdcAddress: HASHPRICE, + btcUsdcFeedAddress: BTC_FEED, + priceMoveTriggerBps: 0, + }, + perps: { address: PERPS }, + } 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..d0c29cb --- /dev/null +++ b/keeper/tests/predict/coordinator.test.ts @@ -0,0 +1,324 @@ +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 === "collateralToken") { + return "0x000000000000000000000000000000000000aa05"; + } + 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, + netEntryValue: (perpNetQty * perpEntry) / 1_000_000n, + }; + case "getRiskView": + return { + netPositionDelta: 0n, + unrealizedPnl: 0n, + pendingFunding: 0n, + buyOrderDelta: 0n, + sellOrderDelta: 0n, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, + }; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getOrderAggregateAtExpiration": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getActiveExpirationDates": + return []; + case "getExpirationDates": + return []; + case "computePortfolioIM": + return balance / 2n; + case "computePortfolioMM": { + // 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..7dd7bd2 --- /dev/null +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -0,0 +1,265 @@ +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 === "collateralToken") { + return "0x000000000000000000000000000000000000aa05"; + } + 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, netEntryValue: perpEntry }; + case "getRiskView": + return { + netPositionDelta: 0n, + unrealizedPnl: 0n, + pendingFunding: 0n, + buyOrderDelta: 0n, + sellOrderDelta: 0n, + buyOrderFillLoss: 0n, + sellOrderFillLoss: 0n, + }; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getOrderAggregateAtExpiration": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getActiveExpirationDates": + return []; + case "getExpirationDates": + return []; + case "computePortfolioIM": + return imAtPriceTokens(currentPrice); + case "computePortfolioMM": + 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/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..4aa760d --- /dev/null +++ b/keeper/tests/predict/snapshot.test.ts @@ -0,0 +1,258 @@ +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"; +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; + +function makeConfig(): Config { + return { + vault: { address: VAULT }, + pme: { address: PME }, + perps: { address: PERPS }, + futures: { address: FUTURES }, + } as Config; +} + +function makeChain(scripted: { + activeExpirationAts?: readonly bigint[]; + /** Tradable window for futures order aggregates; defaults to activeExpirationAts. */ + tradableExpirationAts?: readonly bigint[]; + futuresPositions?: Record; + /** Keyed by expiry; absent means the expiry has not settled. */ + settlementPrices?: Record; + perpNetQty?: bigint; + perpNetEntryValue?: bigint; + perpFunding?: bigint; + perpOrders?: RestingOrders; + futuresOrders?: RestingOrders; + balance?: bigint; + imShock?: bigint; + mmShock?: bigint; + tokenDecimals?: number; + perpQtyDecimals?: number; +}): Chain { + return { + publicClient: { + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "collateralToken") return USDC; + throw new Error(`unexpected readContract: ${functionName}`); + }, + multicall: async ({ + contracts, + }: { + contracts: readonly { functionName: string; args?: readonly unknown[]; address?: Address }[]; + }) => { + return contracts.map((c) => { + switch (c.functionName) { + case "balanceOf": + return scripted.balance ?? 0n; + case "getUserPosition": { + // Perps: getUserPosition(user). Futures: getUserPosition(user, expirationAt). + if ((c.args?.length ?? 0) >= 2) { + 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 { + netQuantity: scripted.perpNetQty ?? 0n, + netEntryValue: scripted.perpNetEntryValue ?? 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 "getOrderAggregate": { + // Perps-only cross-user aggregate. + const orders = scripted.perpOrders ?? NO_ORDERS; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: orders.buyValue, + sellValue: orders.sellValue, + }; + } + case "getOrderAggregateAtExpiration": { + const orders = scripted.futuresOrders ?? NO_ORDERS; + const dates = + scripted.tradableExpirationAts ?? scripted.activeExpirationAts ?? []; + // Put the full venue totals on the first expiry so a single-window + // sum matches the scripted RestingOrders values. + const expirationAt = c.args?.[1] as bigint; + const isFirst = dates.length === 0 || expirationAt === dates[0]; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: isFirst ? orders.buyValue : 0n, + sellValue: isFirst ? orders.sellValue : 0n, + }; + } + case "getActiveExpirationDates": + return scripted.activeExpirationAts ?? []; + case "getExpirationDates": { + if (scripted.tradableExpirationAts !== undefined) { + return scripted.tradableExpirationAts; + } + if (scripted.activeExpirationAts !== undefined) { + return scripted.activeExpirationAts; + } + // Flat accounts still need one window slot when futures order + // totals are scripted without explicit expiries. + return scripted.futuresOrders === undefined ? [] : [0n]; + } + case "imSpotShock": + return scripted.imShock ?? 10n ** 17n; + case "mmSpotShock": + 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.entryPrice, 0n); + assert.equal(snap.perp.fundingOwed, 0n); + assert.equal(snap.futures.positions.length, 0); + assert.deepEqual(snap.perp.orders, NO_ORDERS); + assert.deepEqual(snap.futures.orders, NO_ORDERS); + }); + + it("pairs each venue's risk deltas with its order aggregate 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 () => { + 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("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], + futuresPositions: { + [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 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); + 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/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 new file mode 100644 index 0000000..8dda8dd --- /dev/null +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -0,0 +1,447 @@ +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 { 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, + 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 = { + version: "test", + chain: { + network: "hardhat", + rpcUrl: "http://stub", + discoveryMode: "events", + backfillChunkSize: 10_000n, + }, + vault: { address: "0x0000000000000000000000000000000000000001" }, + perps: { address: "0x0000000000000000000000000000000000000002" }, + futures: { address: "0x0000000000000000000000000000000000000003", maxLotsPerLiquidationTx: 50 }, + 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, + }, + delivery: { + enabled: false, + sweepIntervalMs: 60_000, + settleDelayMs: 0, + bootstrapUsers: [], + maxBatchSize: 50, + }, +}; + +interface Knobs { + executorRunning: boolean; + inflight: 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 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 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.predictor !== undefined + ? ({ + // 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.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: STUB_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("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, + inflight: 0, + 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, + SIGNER, + tracker, + executor, + queue, + silentLogger, + predictor, + priceFeed, + ); + const snap = hc.snapshot(); + assert.equal(snap.executorRunning, 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("returns empty roster arrays when predictor / priceFeed are not wired", () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: false, + inflight: 0, + }); + const hc = new Healthcheck(config, SIGNER, tracker, executor, queue, silentLogger); + const snap = hc.snapshot(); + assert.equal(snap.executorRunning, 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); + }); + + 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", () => { + 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(), { + version: "test", + network: "hardhat", + discoveryMode: "events", + dryRun: "false", + deliveryEnabled: "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, + }); + }); +}); + +/** + * `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. + */ +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, + inflight: 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, + SIGNER, + tracker, + executor, + queue, + silentLogger, + predictor, + priceFeed, + ); + hc.start(); + hc.markReady(); + try { + return await fn(portOf(hc)); + } finally { + await hc.stop(); + } +} + +describe("runtime/healthcheck: HTTP endpoints", () => { + 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.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); + + const ready = await fetch(`http://127.0.0.1:${port}/ready`); + assert.equal(ready.status, 200); + assert.deepEqual(await ready.json(), { ready: true }); + }); + }); + + 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/); + // 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_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}"`)); + }); + }); + + 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); + }); + }); + + 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", () => { + it("returns 503 when the executor is stopped", async () => { + const { config, tracker, executor, queue } = makeStubs({ + executorRunning: false, + inflight: 0, + }); + 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`); + 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, + inflight: 0, + predictor: { thresholds: [], inflight: [] }, + }); + // No priceFeed provided → snapshot returns currentPrice: null. + const hc = new Healthcheck(config, SIGNER, 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/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/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/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/futures-marketid.test.ts b/keeper/tests/venues/futures-marketid.test.ts new file mode 100644 index 0000000..8c54d89 --- /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 { expirationAtMarketId, marketIdToExpirationAt } from "../../src/venues/futures.ts"; + +describe("futures venue marketId helpers", () => { + it("encodes a delivery date as bytes32 and round-trips", () => { + 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(marketIdToExpirationAt(id), expirationAt); + }); + + it("encodes 0 as the zero bytes32", () => { + assert.equal( + expirationAtMarketId(0n), + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + }); +}); diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts new file mode 100644 index 0000000..749ad8d --- /dev/null +++ b/keeper/tests/venues/futures.test.ts @@ -0,0 +1,174 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address, Hex } from "viem"; +import { FuturesVenue, expirationAtMarketId } 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; + +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; + +function makeReadHandler( + marketPrice: bigint, + listResult: readonly unknown[], + opts: { orderIdsByExpiry?: Record } = {}, +) { + return (call: ReadCall): unknown => { + if (call.functionName === "getMarketPrice") return marketPrice; + 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}`); + }; +} + +describe("futures venue: marketLabel", () => { + it("renders expirationAt as an ISO date prefix", () => { + const venue = new FuturesVenue(makeChainStub({}), makeConfigStub(), silentLogger); + const id = expirationAtMarketId(DELIVERY_AT); + assert.equal(venue.marketLabel(id), "futures 2025-08-28"); + }); +}); + +describe("futures venue: readOpenOrders", () => { + it("returns empty when the tradable window has no dates (no multicall)", async () => { + let multicallCount = 0; + const chain = makeChainStub({ + readContract: makeReadHandler(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 tradable dates"); + }); + + it("hydrates each order's expirationAt as its marketId", async () => { + const orderIds: Hex[] = [ + "0x000000000000000000000000000000000000000000000000000000000000000a", + "0x000000000000000000000000000000000000000000000000000000000000000b", + ]; + const expiryB = DELIVERY_AT + 86_400n; + let multicallStep = 0; + const chain = makeChainStub({ + 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: expiryB, price: 60n, quantity: -1n }, + ]; + }, + }); + 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, expirationAtMarketId(DELIVERY_AT)); + assert.equal(orders[1]?.marketId, expirationAtMarketId(expiryB)); + }); +}); + +describe("futures venue: readPositions", () => { + it("returns empty when getActiveExpirationDates is empty", async () => { + const chain = makeChainStub({ + readContract: makeReadHandler(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 when market drops below entry", async () => { + const entry = 100n; + const marketPrice = 70n; + const chain = makeChainStub({ + readContract: makeReadHandler(marketPrice, [DELIVERY_AT]), + multicall: (calls) => { + assert.equal(calls.length, 1); + 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, entry - marketPrice); + assert.equal(pos.notional, entry); + assert.equal(pos.marketId, expirationAtMarketId(DELIVERY_AT)); + }); + + it("computes short-side underwater PnL when market rises above entry", async () => { + const entry = 100n; + const marketPrice = 130n; + const chain = makeChainStub({ + 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, marketPrice - entry); + assert.equal(pos.notional, entry); + }); +}); diff --git a/keeper/tests/venues/perps.test.ts b/keeper/tests/venues/perps.test.ts new file mode 100644 index 0000000..9e556b5 --- /dev/null +++ b/keeper/tests/venues/perps.test.ts @@ -0,0 +1,185 @@ +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, netEntryValue: 0n }, + 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, netEntryValue: (qty * entryPrice) / QTY_SCALE }, + 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, netEntryValue: (qty * entryPrice) / QTY_SCALE }, + 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, netEntryValue: (qty * entryPrice) / QTY_SCALE }, + 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("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, netEntryValue: 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/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts new file mode 100644 index 0000000..ba8d14e --- /dev/null +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -0,0 +1,201 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { FuturesVenue } 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 USER = "0x0000000000000000000000000000000000000b0b" as Address; +const USDC = "0x000000000000000000000000000000000000aa05" as Address; +const EXPIRY = 1_756_416_000n; + +const IM_SHOCK = 10n ** 17n; +const MM_SHOCK = 5n * 10n ** 16n; + +interface ReadCall { + functionName: string; + 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, perp position, + * perps risk/aggregate, futures risk, active position expiries, tradable window. + * Only the expiry lists vary between these cases. + */ +function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { + return [ + balance, + { netQuantity: 0n, netEntryValue: 0n }, + EMPTY_RISK_VIEW, + [0n, 0n], + EMPTY_RISK_VIEW, + expiries, + expiries, + ]; +} + +const silentLogger = { + child: () => silentLogger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} as unknown as ConstructorParameters[2]; + +function makeConfigStub(dryRun: boolean, maxLots = 50): Config { + return { + 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; +} + +function makeChainStub(opts: { + balance: bigint; + marketPrice: bigint; + netQuantity: bigint; + netEntryValue: 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 === "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 snapshotMulticall(opts.balance, [EXPIRY]); + } + if (fns[0] === "getUserPosition") { + // 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]}`); + }, + simulateContract: async (call: ReadCall) => { + opts.onSimulate(call); + return { request: { ...call } }; + }, + }, + } as unknown as Chain; +} + +describe("futures venue: reduceToTarget", () => { + it("sizes a closeQty and submits liquidatePositions(expirationAts, closeQtys)", async () => { + let simulated: ReadCall | undefined; + const chain = makeChainStub({ + balance: 136_000_000n, + marketPrice: 30_000_000n, + netQuantity: 12n, + netEntryValue: 12n * 40_000_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, expirationAts, closeQtys] = simulated?.args as [ + Address, + bigint[], + bigint[], + ]; + assert.equal(participant, USER); + 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 () => { + const EXPIRY_B = EXPIRY + 86_400n; + let simulated: ReadCall | undefined; + const chain = { + account: { address: "0x0000000000000000000000000000000000009999" as Address }, + 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 snapshotMulticall(1_000_000n, [EXPIRY, EXPIRY_B, EXPIRY + 172_800n]); + } + 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, + netEntryValue: 4n * 40_000_000n, + _expirationAt: expirationAt, + }; + }); + } + throw new Error(`unexpected multicall head: ${fns[0]}`); + }, + simulateContract: async (call: ReadCall) => { + simulated = call; + return { request: { ...call } }; + }, + }, + } as unknown as Chain; + + const venue = new FuturesVenue(chain, makeConfigStub(true, 2), silentLogger); + const outcome = await venue.reduceToTarget(USER); + assert.ok(simulated); + const [, expirationAts] = simulated?.args as [Address, bigint[], bigint[]]; + assert.equal(expirationAts.length, 2, "capped to 2 expiry legs"); + assert.ok("feeEarned" in outcome); + }); + + it("returns nothingToClose when already healthy", async () => { + const chain = makeChainStub({ + balance: 1_000_000_000n, + marketPrice: 30_000_000n, + netQuantity: 1n, + netEntryValue: 40_000_000n, + onSimulate: () => { + throw new Error("should not simulate"); + }, + }); + const venue = new FuturesVenue(chain, makeConfigStub(true), silentLogger); + const outcome = await venue.reduceToTarget(USER); + assert.deepEqual(outcome, { skipped: "nothingToClose" }); + }); +}); 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/.DS_Store b/market-maker/.DS_Store new file mode 100644 index 0000000..3371498 Binary files /dev/null and b/market-maker/.DS_Store differ diff --git a/market-maker/.cicd_trigger b/market-maker/.cicd_trigger new file mode 100644 index 0000000..6935ae8 --- /dev/null +++ b/market-maker/.cicd_trigger @@ -0,0 +1 @@ +17744633303 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/.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/.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 new file mode 100644 index 0000000..86d3880 --- /dev/null +++ b/market-maker/Dockerfile @@ -0,0 +1,36 @@ +# ── Install deps ────────────────────────────────────────────────────────────── +FROM node:24-alpine AS deps + +WORKDIR /app + +RUN corepack enable + +# 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 node:24-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/ +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 + +# 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 new file mode 100644 index 0000000..b43352a --- /dev/null +++ b/market-maker/README.md @@ -0,0 +1,237 @@ +# Market Maker + +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 + +A single poll loop (`tick`) reads on-chain state, computes desired +quotes, and reconciles them against resting orders. + +```mermaid +graph LR + subgraph On-chain + 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 + + OT -- price, volatility --> Q[Quoter] + GT -- gas price, spike % --> Q + IM -- skew, utilization --> Q + GT -- gas budget --> RM[RiskManager] + CT -- collateral, IM/MM --> RM + RM -- allowed sides, halt --> Q + Q -- desired bids & asks --> OE[OrderExecutor] + OE -- cancel / place --> V + BT -- own orders --> OE + V -. events .-> BT + CT -. balanceOf, IM/MM .-> Vault + CT -. canPlaceOrder .-> Engine + OE -- gas cost --> RM + OE -- stats --> HC[HealthCheck] + + HC -. "GET /health" .-> Mon[Monitoring] +``` + +### Components + +| Component | File | Role | +|---|---|---| +| **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 + +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/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 + +- **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**: geometric taper — front level is largest; each + deeper level is `taperRatio` × the previous + +### 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 triples +- **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 + +### Stale-order policy + +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 + +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 + +Each app ships per-environment YAML configs under `configs/`: + +| 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 +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 + +# One-off / custom path: +node src/apps/perps/main.ts --config /tmp/my-perps.yml +``` + +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. + +### Required environment variables + +These must be set in `.env` (or the live environment); everything +else lives in YAML. + +| Variable | Required by | Description | +|---|---|---| +| `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 | + +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 + +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 + +```bash +cd market-maker +pnpm install +``` + +### Run + +```bash +# Local hardhat (pretty-printed logs, dry-run on by default) +pnpm local:perps +pnpm local:futures + +# base-sepolia (dev testnet) +pnpm dev:perps +pnpm dev:futures + +# base-mainnet (staging — pre-prod sizes) +pnpm stg:perps +pnpm stg:futures + +# base-mainnet (production) +pnpm prd:perps +pnpm prd:futures +``` + +## Health endpoint + +`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 + +```bash +pnpm test +``` + +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/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml new file mode 100644 index 0000000..02856ea --- /dev/null +++ b/market-maker/configs/futures.dev.yml @@ -0,0 +1,94 @@ +# 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 +# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill + +nodeEnv: development +commitHash: ${COMMIT_HASH:-unknown} +logLevel: ${MAKER_LOG_LEVEL:-debug} +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: + privateKey: ${PRIVATE_KEY} + +network: + name: base-sepolia + rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + 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: 8 # venue-native (contracts base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 100 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 10 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: true + autoDepositMinAmount: 500 + maxCollateralAmount: 5000 + +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} + +readBatchSize: 30 +# Per-operation batch sizes for writes (futures: closeOrder limit / createOrders limit). +writeBatchSize: 100 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml new file mode 100644 index 0000000..6c46f23 --- /dev/null +++ b/market-maker/configs/futures.local.yml @@ -0,0 +1,81 @@ +# 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: 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: "hardhat" + rpcUrl: "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 + marginCallTimeSec: 3600 + minSpreadBps: 20 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "10000000" # venue-native (contracts base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + +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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +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 + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml new file mode 100644 index 0000000..78c796f --- /dev/null +++ b/market-maker/configs/futures.prd.yml @@ -0,0 +1,87 @@ +# 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 +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup + +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} + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "500000000" # venue-native (contracts base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + +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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +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 + # 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} + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml new file mode 100644 index 0000000..b560c94 --- /dev/null +++ b/market-maker/configs/futures.stg.yml @@ -0,0 +1,87 @@ +# 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 +# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup + +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} + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: futures + address: ${FUTURES_ADDRESS} + wallet: primary + +pricing: + strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0 + +sizing: + strategy: geometric-taper + baseQuantity: "100000000" # venue-native (contracts base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + +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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +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 + # 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} + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml new file mode 100644 index 0000000..9eebf9a --- /dev/null +++ b/market-maker/configs/perps.dev.yml @@ -0,0 +1,98 @@ +# 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 +# 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} +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: true + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +network: + name: base-sepolia + rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venue: + kind: perps + address: ${PERPS_ADDRESS} + wallet: primary + +pricing: + strategy: effective-spread + # 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: geometric-taper + baseQuantity: "1000000" # venue-native units (hashrate base) + numLevelsPerSide: 10 + taperRatio: 0.6 + +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: 30 + 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 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: true + autoDepositMinAmount: 500 + maxCollateralAmount: 5000 + +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} + +readBatchSize: 100 +# Per-operation batch sizes for writes (perps: individual cancelOrder / createOrder). +writeBatchSize: 100 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml new file mode 100644 index 0000000..24fe3d9 --- /dev/null +++ b/market-maker/configs/perps.local.yml @@ -0,0 +1,88 @@ +# 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} +# 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: 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: geometric-taper + baseQuantity: "100000" # venue-native units (hashrate base) + numLevelsPerSide: 10 + taperRatio: 0.6 + +risk: + # All *Usd fields are USD (decimals OK). Loader converts to 6-dec USDC. + maxPositionSize: 10 + maxUtilizationPct: 80 + minCollateralBalance: 1 + maxDailyLossUsd: 100 + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + # All *Sec fields are seconds (decimals OK). Loader converts to ms. + pollIntervalSec: 3 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + # Off in dev so you can inspect un-deposited wallet balance. + 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} + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml new file mode 100644 index 0000000..b16bd18 --- /dev/null +++ b/market-maker/configs/perps.prd.yml @@ -0,0 +1,90 @@ +# 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) +# 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} +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} + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + 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: geometric-taper + baseQuantity: "10000000" # venue-native units (hashrate base) + numLevelsPerSide: 10 + taperRatio: 0.6 + +risk: + maxPositionSize: 1000 + maxUtilizationPct: 75 # tighter than dev/stg + minCollateralBalance: 100 # operational floor + maxDailyLossUsd: 1000 # daily loss circuit-breaker + maxGasBudgetPerHourUsd: 50 + maxGasBudgetPerDayUsd: 500 + gasSpikeThresholdPct: 200 + gasPenaltyBps: 5 + urgentRequoteThresholdTicks: 10 + +gas: + gasCapMultiplier: 2.0 + +timing: + pollIntervalSec: 3 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +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 + # 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} + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml new file mode 100644 index 0000000..821792c --- /dev/null +++ b/market-maker/configs/perps.stg.yml @@ -0,0 +1,89 @@ +# 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 +# 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} +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} + +network: + name: base + rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} + 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: geometric-taper + baseQuantity: "1000000" # venue-native units (hashrate base) + numLevelsPerSide: 10 + taperRatio: 0.6 + +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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 5 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +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 + # 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} + +readBatchSize: 100 +# Per-operation batch sizes for writes. +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml new file mode 100644 index 0000000..c12594a --- /dev/null +++ b/market-maker/configs/portfolio.dev.yml @@ -0,0 +1,143 @@ +# 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: geometric-taper + baseQuantity: "1000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + + - 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 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # 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: 10 + taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: true + autoDepositMinAmount: 500 + # 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 + # 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. + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml new file mode 100644 index 0000000..9b4f980 --- /dev/null +++ b/market-maker/configs/portfolio.local.yml @@ -0,0 +1,122 @@ +# 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: geometric-taper + baseQuantity: "10000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + + - 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 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # 10e6×10/1e6 = 100. Split across 3 expiries: 4×10×3 = 120. + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 10 + taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: false + autoDepositMinAmount: 0 + +oracle: + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + +health: + port: 3001 + +# Centralized submission / nonce recovery. +txCoordinator: + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml new file mode 100644 index 0000000..bd826f1 --- /dev/null +++ b/market-maker/configs/portfolio.prd.yml @@ -0,0 +1,136 @@ +# 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: geometric-taper + baseQuantity: "10000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + + - 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 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # baseQuantity×numLevels/1e6 = 100. Split across `count` expiries: + # futures_base × futures_levels × expiries ≈ perps_total + # 4 × 10 × 3 = 120 ≈ 100. Order count is 2×levels×expiries (=60). + baseQuantity: 4 # venue-native (contracts) + numLevelsPerSide: 10 + taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: true + autoDepositMinAmount: 1 + maxCollateralAmount: 10000 + +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. + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml new file mode 100644 index 0000000..e2b0b37 --- /dev/null +++ b/market-maker/configs/portfolio.stg.yml @@ -0,0 +1,135 @@ +# 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: geometric-taper + baseQuantity: "1000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 10 + taperRatio: 0.6 + + - 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 + # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = + # 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: 10 + taperRatio: 0.6 + # Nearest expiry keeps full size; each further date × this factor. + expirySizeDecay: 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 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + staleBandAllowanceUsd: 0.03 + staleSizeAllowanceUsd: 50 + +collateral: + autoDeposit: true + autoDepositMinAmount: 1 + maxCollateralAmount: 4000 + +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. + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 100 diff --git a/market-maker/docker-entrypoint.sh b/market-maker/docker-entrypoint.sh new file mode 100755 index 0000000..ad5ccd1 --- /dev/null +++ b/market-maker/docker-entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Container entrypoint. +# +# Required: +# 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 … 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', 'futures', or 'portfolio'" >&2 + exit 1 +fi + +case "$MAKER_APP" in + perps) + ENTRY="/app/src/apps/perps/main.ts" + ;; + futures) + ENTRY="/app/src/apps/futures/main.ts" + ;; + portfolio) + ENTRY="/app/src/apps/portfolio/main.ts" + ;; + *) + echo "Unknown MAKER_APP='$MAKER_APP' (expected 'perps', 'futures', or 'portfolio')" >&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 --import=amaro/strip --conditions=typescript "$ENTRY" "$@" diff --git a/market-maker/docs/stale-order-policy.md b/market-maker/docs/stale-order-policy.md new file mode 100644 index 0000000..af2e69f --- /dev/null +++ b/market-maker/docs/stale-order-policy.md @@ -0,0 +1,80 @@ +# 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 `50` (~1 futures contract at ~$95). +- **On-grid**: resting `(side, price)` equals a desired intent price. +- **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 + +| Resting order | Action | +|---|---| +| 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 | +| 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) | + +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 + +`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)` + +- 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 ~`$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`) + +| 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/package.json b/market-maker/package.json new file mode 100644 index 0000000..49258e0 --- /dev/null +++ b/market-maker/package.json @@ -0,0 +1,53 @@ +{ + "name": "titan-market-maker", + "version": "1.0.0", + "type": "module", + "private": true, + "engines": { + "node": ">=22.9.0" + }, + "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", + "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", + "portfolio": "pnpm node --watch src/apps/portfolio/main.ts", + "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": { + "@sinclair/typebox": "^0.34.49", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "amaro": "^1.1.9", + "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts", + "fraction.js": "^5.2.2", + "js-yaml": "^4.1.0", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts", + "pino": "^10.3.1", + "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", + "pino-pretty": "^13.1.3", + "typescript": "^5.8.0" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml new file mode 100644 index 0000000..127eddb --- /dev/null +++ b/market-maker/pnpm-lock.yaml @@ -0,0 +1,5826 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 + ajv: + specifier: ^8.17.1 + version: 8.18.0 + 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: github:Lumerin-protocol/collateral-margin#dev&path:/contracts + 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 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + perps-contracts: + 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 + viem: + 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 + '@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 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@arbitrum/nitro-contracts@3.0.0': + resolution: {integrity: sha512-7VzNW9TxvrX9iONDDsi7AZlEUPa6z+cjBkB4Mxlnog9VQZAapRC3CdRXyUzHnBYmUhRzyNJdyxkWPw59QGcLmA==} + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@1.2.2': + resolution: {integrity: sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@1.2.2': + resolution: {integrity: sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-lambda@3.1042.0': + resolution: {integrity: sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + engines: {node: '>=20.0.0'} + + '@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: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/util-utf8-browser@3.259.0': + resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} + + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + + '@babel/runtime@7.29.2': + 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==} + + '@chainlink/contracts@1.5.0': + resolution: {integrity: sha512-1fGJwjvivqAxvVOTqZUEXGR54CATtg0vjcXgSIk4Cfoad2nUhSG/qaWHXjLg1CkNTeOoteoxGQcpP/HiA5HsUA==} + engines: {node: '>=22', pnpm: '>=10'} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@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: + ethers: ^5 + + '@eth-optimism/core-utils@0.12.0': + resolution: {integrity: sha512-qW+7LZYCz7i8dRa7SRlUKIo1VBU8lvN0HeXCxJR+z+xtMzMQpPds20XJNCMclszxYQHkXY00fOT6GvFw9ZL6nw==} + + '@ethereumjs/rlp@5.0.2': + resolution: {integrity: sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA==} + engines: {node: '>=18'} + hasBin: true + + '@ethereumjs/util@9.1.0': + resolution: {integrity: sha512-XBEKsYqLGXLah9PNJbgdkigthkG7TAGvlD/sH12beMXEyHDyigfcbdvHhmLyDWgDyOJn4QwiQUaF7yeuhnjdog==} + engines: {node: '>=18'} + + '@ethersproject/abi@5.8.0': + resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==} + + '@ethersproject/abstract-provider@5.8.0': + resolution: {integrity: sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==} + + '@ethersproject/abstract-signer@5.8.0': + resolution: {integrity: sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==} + + '@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: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@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/hashes@1.2.0': + resolution: {integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==} + + '@noble/hashes@1.4.0': + 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/secp256k1@1.7.1': + resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} + + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + + '@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'} + + '@nomicfoundation/edr-darwin-arm64@0.12.0-next.23': + resolution: {integrity: sha512-Amh7mRoDzZyJJ4efqoePqdoZOzharmSOttZuJDlVE5yy07BoE8hL6ZRpa5fNYn0LCqn/KoWs8OHANWxhKDGhvQ==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-darwin-x64@0.12.0-next.23': + resolution: {integrity: sha512-9wn489FIQm7m0UCD+HhktjWx6vskZzeZD9oDc2k9ZvbBzdXwPp5tiDqUBJ+eQpByAzCDfteAJwRn2lQCE0U+Iw==} + engines: {node: '>= 20'} + + '@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: + ethers: ^6.14.0 + hardhat: ^2.28.0 + + '@nomicfoundation/slang@0.18.3': + resolution: {integrity: sha512-YqAWgckqbHM0/CZxi9Nlf4hjk9wUNLC9ngWCWBiqMxPIZmzsVKYuChdlrfeBPQyvQQBoOhbx+7C1005kLVQDZQ==} + + '@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: + '@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: + '@nomicfoundation/hardhat-verify': + optional: true + + '@openzeppelin/upgrades-core@1.44.2': + resolution: {integrity: sha512-m6iorjyhPK9ow5/trNs7qsBC/SOzJCO51pvvAF2W9nOiZ1t0RtCd+rlRmRmlWTv4M33V0wzIUeamJ2BPbzgUXA==} + hasBin: true + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@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==} + + '@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==} + + 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==} + + 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==} + + 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'} + + 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.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + 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-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts: + 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: {gitHosted: true, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6} + 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/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts: + 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} + + 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'} + + 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'} + + 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: {gitHosted: true, 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==} + + 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/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: + resolution: {gitHosted: true, 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.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + + 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 + + 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.7: + resolution: {integrity: sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==} + 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: {gitHosted: true, tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + 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 + + 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'} + + 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.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + 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'} + + 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.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + 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.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + 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 + + 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==} + + 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.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + 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 + + 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'} + + 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.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + 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': {} + + '@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)': + 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.8.5 + 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.8.5 + + '@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.8.5 + + '@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.8.5 + 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.8.5 + + '@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.3.0 + + '@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.3.0 + 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 + + '@pinojs/redact@0.4.0': {} + + '@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 + + '@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): + 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 + + amaro@1.1.9: {} + + 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: {} + + 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.2: + 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-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) + dotenv: 16.6.1 + 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/9372f537a57682bb126aede41aac51be004828a6: {} + + 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/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 + '@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 + 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 + + dotenv@16.6.1: {} + + 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.5): + optionalDependencies: + picomatch: 4.0.5 + + 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: {} + + 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.9 + io-ts: 1.10.4 + json-stream-stringify: 3.1.7 + 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.17 + tsort: 0.0.1 + undici: 5.29.0 + uuid: 8.3.2 + ws: 7.5.13 + 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/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.9: {} + + 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 + + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + + 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 + + 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.7: {} + + 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 + + lodash.isequal@4.5.0: {} + + lodash.startcase@4.4.0: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + lru_map@0.3.3: {} + + math-intrinsics@1.1.0: {} + + md5.js@1.3.5: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + 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: + '@scure/base': 1.2.6 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minimist@1.2.8: {} + + mnemonist@0.38.5: + dependencies: + obliterator: 2.0.5 + + 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.3.0 + 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.2 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + + mri@1.2.0: {} + + 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: {} + + node-addon-api@5.1.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + + nofilter@3.1.0: {} + + normalize-path@3.0.0: {} + + obliterator@2.0.5: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + 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 + '@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 + + 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 + + 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.5: {} + + pify@4.0.1: {} + + 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 + + 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: {} + + 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.8.5: {} + + 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.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + 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 + '@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 + + 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: + 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.13: {} + + ws@8.18.0: {} + + ws@8.18.3: {} + + ws@8.21.0: {} + + 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.2: + 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/pnpm-workspace.yaml b/market-maker/pnpm-workspace.yaml new file mode 100644 index 0000000..65c6398 --- /dev/null +++ b/market-maker/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + "@arbitrum/nitro-contracts": false + keccak: true + secp256k1: true diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json new file mode 100644 index 0000000..02eb4a2 --- /dev/null +++ b/market-maker/schemas/futures.json @@ -0,0 +1,1006 @@ +{ + "$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", + "pricing", + "sizing", + "risk", + "gas", + "collateral", + "oracle", + "timing", + "health", + "readBatchSize", + "writeBatchSize" + ], + "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 /health for ops correlation.", + "type": "string" + }, + "logLevel": { + "default": "info", + "description": "Pino log level (trace/debug/info/warn/error/fatal).", + "type": "string" + }, + "dryRun": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "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" + ], + "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." + } + } + } + } + }, + "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)" + } + ] + } + ] + } + } + }, + "venue": { + "additionalProperties": false, + "description": "Futures venue identification and signer selection.", + "type": "object", + "required": [ + "kind", + "address", + "wallet" + ], + "properties": { + "kind": { + "description": "Venue type — must be 'futures' for the Futures contract.", + "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 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", + "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.expirationAt 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." + }, + "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 + } + } + }, + "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", + "requoteCooldownSec", + "resyncIntervalSec", + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" + ], + "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)" + } + ] + } + ] + }, + "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 + }, + "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": 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": [ + { + "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)" + } + ] + } + ] + } + } + }, + "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 + } + } + }, + "readBatchSize": { + "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 + }, + "writeBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "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 new file mode 100644 index 0000000..1929c59 --- /dev/null +++ b/market-maker/schemas/perps.json @@ -0,0 +1,983 @@ +{ + "$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", + "pricing", + "sizing", + "risk", + "gas", + "collateral", + "oracle", + "timing", + "health", + "readBatchSize", + "writeBatchSize" + ], + "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": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "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" + ], + "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." + } + } + } + } + }, + "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)" + } + ] + } + ] + } + } + }, + "venue": { + "additionalProperties": false, + "description": "Perps venue identification and signer selection.", + "type": "object", + "required": [ + "kind", + "address", + "wallet" + ], + "properties": { + "kind": { + "description": "Venue type — must be 'perps' for HashPowerPerpsDEX.", + "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 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", + "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": "Geometric-taper sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide", + "taperRatio" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Perps lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", + "type": "string" + }, + "baseQuantity": { + "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": [ + { + "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", + "requoteCooldownSec", + "resyncIntervalSec", + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" + ], + "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)" + } + ] + } + ] + }, + "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 + }, + "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": 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": [ + { + "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)" + } + ] + } + ] + } + } + }, + "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 + } + } + }, + "readBatchSize": { + "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.", + "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)" + } + ], + "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 new file mode 100644 index 0000000..2471905 --- /dev/null +++ b/market-maker/schemas/portfolio.json @@ -0,0 +1,1562 @@ +{ + "$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": "Geometric-taper sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide", + "taperRatio" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Perps lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", + "type": "string" + }, + "baseQuantity": { + "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": [ + { + "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." + } + } + } + } + }, + { + "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.expirationAt 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." + }, + "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 + } + } + } + } + } + ] + } + }, + "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", + "requoteCooldownSec", + "resyncIntervalSec", + "levelSpacingTicks", + "staleBandAllowanceUsd", + "staleSizeAllowanceUsd" + ], + "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)" + } + ] + } + ] + }, + "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 + }, + "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": 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": [ + { + "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)" + } + ] + } + ] + } + } + }, + "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": [ + "confirmationTimeoutSec", + "maxReplacements", + "replacementFeeBumpPct", + "maxNonceResyncs" + ], + "properties": { + "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 + }, + "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 + } + } + }, + "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": 100 + } + } +} diff --git a/market-maker/scripts/fetch-logs.sh b/market-maker/scripts/fetch-logs.sh new file mode 100755 index 0000000..a10a614 --- /dev/null +++ b/market-maker/scripts/fetch-logs.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +# Fetch and pretty-print CloudWatch logs for the Titan market maker. +# +# Usage: +# 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 + +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|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 \ + --format short \ + | sed -E 's/^[^ ]+ //' \ + | pnpm pino-pretty diff --git a/market-maker/scripts/gen-schemas.ts b/market-maker/scripts/gen-schemas.ts new file mode 100644 index 0000000..e78880f --- /dev/null +++ b/market-maker/scripts/gen-schemas.ts @@ -0,0 +1,101 @@ +// 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"; +import { portfolioRootSchema } from "../src/apps/portfolio/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" }, + { name: "portfolio", schema: portfolioRootSchema, title: "Titan Market Maker - Portfolio 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/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts new file mode 100644 index 0000000..f4d4ae3 --- /dev/null +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -0,0 +1,1979 @@ +export const HashPowerFuturesAbi = [ + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "ArrayLengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyBatch", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateNotAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateShouldBeInTheFuture", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMarginBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidFee", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidQty", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReduceQuantity", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimeInForce", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantPerExpirationReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NotLiquidatable", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToUser", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "OrdersStillOpen", + "type": "error" + }, + { + "inputs": [], + "name": "OverLiquidation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "PositionExpirationNotStartedYet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "SettlementDateNotReached", + "type": "error" + }, + { + "inputs": [], + "name": "TimeInForceNotFilled", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnsupportedTokenDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int256", + "name": "min", + "type": "int256" + }, + { + "internalType": "int256", + "name": "max", + "type": "int256" + } + ], + "name": "ValueOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BadDebt", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newFutureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "FutureExpirationDatesCountUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "hook", + "type": "address" + } + ], + "name": "HookUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidationFeeBps", + "type": "uint16" + } + ], + "name": "LiquidationFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newLiquidationMarginPercent", + "type": "uint8" + } + ], + "name": "LiquidationMarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidatorShareBps", + "type": "uint16" + } + ], + "name": "LiquidatorShareBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newMakerFeeBps", + "type": "int16" + } + ], + "name": "MakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newOracle", + "type": "address" + } + ], + "name": "OracleUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + } + ], + "name": "OrderCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "OrderCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "OrderLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "makerOrderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tradePrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "makerEntryPriceAfter", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "takerEntryPriceAfter", + "type": "uint256" + } + ], + "name": "OrderMatched", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "name": "OrderUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newPortfolioMargin", + "type": "address" + } + ], + "name": "PortfolioMarginUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidatorFee", + "type": "uint256" + } + ], + "name": "PositionLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "settlementPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "settledBy", + "type": "address" + } + ], + "name": "PositionSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "recordedBy", + "type": "address" + } + ], + "name": "SettlementPriceRecorded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newTakerFeeBps", + "type": "int16" + } + ], + "name": "TakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "CONTRACT_SIZE_HPS_DAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXPIRATION_INTERVAL_DAYS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORACLE_STALENESS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORDERS_PER_PARTICIPANT_PER_EXPIRATION", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PRICE_LEVELS_PER_SIDE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "QUANTITY_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collectedFeesBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "_tif", + "type": "uint8" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "createOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "dropActiveOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "expirationIntervalDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "firstFutureExpirationDate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureExpirationDatesCount", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getActiveExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMarketPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "getOrder", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getOrderAggregateAtExpiration", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyValue", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellValue", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderAggregate", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxLevels", + "type": "uint256" + } + ], + "name": "getOrderBookPrices", + "outputs": [ + { + "internalType": "uint256[]", + "name": "bids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "asks", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isBid", + "type": "bool" + } + ], + "name": "getQuantityAtPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getRiskView", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netPositionDelta", + "type": "int256" + }, + { + "internalType": "int256", + "name": "unrealizedPnl", + "type": "int256" + }, + { + "internalType": "int256", + "name": "pendingFunding", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "buyOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyOrderFillLoss", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderFillLoss", + "type": "uint256" + } + ], + "internalType": "struct ILinearMarket.RiskView", + "name": "view_", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserOrdersAtExpiration", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "orderIds", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserPosition", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netQuantity", + "type": "int256" + }, + { + "internalType": "int256", + "name": "netEntryValue", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hook", + "outputs": [ + { + "internalType": "contract IPointsHook", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_priceOracle", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_firstFutureExpirationDate", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "liquidateOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "liquidateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_closeQty", + "type": "uint256" + } + ], + "name": "liquidatePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "_closeQtys", + "type": "uint256[]" + } + ], + "name": "liquidatePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidatorShareBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "makerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "portfolioMargin", + "outputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "priceOracle", + "outputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "recordSettlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "_newQuantity", + "type": "int256" + } + ], + "name": "reduceOrderSize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "removeOutdatedOrders", + "outputs": [ + { + "internalType": "uint256", + "name": "removed", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "resetState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "setFutureExpirationDatesCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_hook", + "type": "address" + } + ], + "name": "setHook", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidationFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + } + ], + "name": "setLiquidationMarginPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidatorShareBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_makerFeeBps", + "type": "int16" + } + ], + "name": "setMakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", + "type": "address" + } + ], + "name": "setOracle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "_pm", + "type": "address" + } + ], + "name": "setPortfolioMargin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_takerFeeBps", + "type": "int16" + } + ], + "name": "setTakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "settlePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_users", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + } + ], + "name": "settlePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "settlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + } + ], + "name": "simulateOrder", + "outputs": [ + { + "internalType": "int256", + "name": "filledQuantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "averageFillPrice", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "remainingQuantity", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "takerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_cancelIds", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.ReduceIntent[]", + "name": "_reduces", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "updateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawCollectedFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts new file mode 100644 index 0000000..408d997 --- /dev/null +++ b/market-maker/src/adapters/futures/events.ts @@ -0,0 +1,134 @@ +import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; +import type { + Unsubscribe, + VenueEvent, + VenueEvents, +} from "../../core/adapter.ts"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; + +/** Instrument id for a futures expiry, e.g. `futures:1893456000`. */ +export function futuresInstrumentId(expirationAt: bigint): string { + return `futures:${expirationAt.toString()}`; +} + +type FuturesLog = Log< + bigint, + number, + false, + undefined, + false, + typeof HashPowerFuturesAbi +>; + +/** 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; + + 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); + 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: HashPowerFuturesAbi, + 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, price, quantity, expirationAt } = log.args; + if ( + !orderId || + !participant || + price === undefined || + quantity === undefined || + expirationAt === undefined + ) + return null; + const absQty = quantity < 0n ? -quantity : quantity; + if (absQty === 0n) return null; + return { + type: "order-created", + orderId, + participant, + price, + side: quantity > 0n ? "buy" : "sell", + size: absQty, + instrumentId: futuresInstrumentId(expirationAt), + expirationAt: expirationAt, + }; + } + 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-updated", + orderId, + participant, + newSize: absQty, + }; + } + case "OrderCancelled": { + const { orderId } = log.args; + if (!orderId) return null; + return { type: "order-cancelled", orderId }; + } + case "OrderMatched": { + 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(expirationAt), + }; + } + case "PositionLiquidated": + case "PositionSettled": { + const { user, expirationAt } = log.args as { + user?: `0x${string}`; + expirationAt?: bigint; + }; + if (!user || expirationAt === undefined) return null; + return { + type: "position-changed", + participant: user, + instrumentId: futuresInstrumentId(expirationAt), + }; + } + default: + return null; + } +} diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts new file mode 100644 index 0000000..ab00c2c --- /dev/null +++ b/market-maker/src/adapters/futures/index.ts @@ -0,0 +1,27 @@ +import type pino from "pino"; +import type { NetworkClients } from "../../core/client.ts"; +import type { VenueAdapter, WalletContext } from "../../core/adapter.ts"; +import { FuturesVenueAdapter, type FuturesMarketSelection } from "./venue.ts"; + +export interface CreateFuturesVenueOpts { + network: NetworkClients; + wallet: WalletContext; + address: `0x${string}`; + multicall3Address?: `0x${string}`; + readBatchSize: number; + writeBatchSize: number; + /** Which delivery dates to quote. Defaults to nearest-only. */ + marketSelection?: FuturesMarketSelection; + logger: pino.Logger; +} + +/** Construct a futures venue adapter. Static wiring — no registry lookup. */ +export async function createFuturesVenue( + opts: CreateFuturesVenueOpts, +): Promise { + return new FuturesVenueAdapter(opts); +} + +export { FuturesVenueAdapter } from "./venue.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 new file mode 100644 index 0000000..7ee8821 --- /dev/null +++ b/market-maker/src/adapters/futures/instrument.ts @@ -0,0 +1,389 @@ +import { encodeFunctionData } from "viem"; +import type pino from "pino"; +import type { + BookSource, + CancelIntent, + DepthLevel, + ExecuteOrdersIntent, + ExecuteOrdersResult, + InstrumentAdapter, + InstrumentContext, + OrderBookSnapshot, + OrderIntent, + Position, + ReduceIntent, +} from "../../core/adapter.ts"; +import { TimeInForce } from "../../core/adapter.ts"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; +import { fillLossFromNotionals } from "../../core/math.ts"; +import type { FuturesVenueAdapter } from "./venue.ts"; +import { FuturesOwnOrders } from "./ownOrders.ts"; +import { futuresInstrumentId } from "./events.ts"; + +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 `expirationAt`. + * + * 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: string; + readonly venue: FuturesVenueAdapter; + readonly book: FuturesBook; + readonly ownOrders: FuturesOwnOrders; + readonly expirationAt: bigint; + + private tickCache: bigint | null = null; + private marginPercentCache: bigint | null = null; + + constructor(venue: FuturesVenueAdapter, expirationAt: bigint, logger: pino.Logger) { + this.venue = venue; + this.expirationAt = expirationAt; + this.id = futuresInstrumentId(expirationAt); + this.book = new FuturesBook(this, venue.readBatchSize); + this.ownOrders = new FuturesOwnOrders( + venue, + expirationAt, + logger.child({ instrument: this.id }), + venue.readBatchSize, + ); + } + + async getIndexPrice(): Promise { + // 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 { + const pos = await this.venue.publicClient.readContract({ + address: this.venue.address, + abi: HashPowerFuturesAbi, + functionName: "getUserPosition", + args: [this.venue.wallet.account.address, this.expirationAt], + }); + const netQuantity = pos.netQuantity; + if (netQuantity === 0n) { + return { netQuantity: 0n, entryPrice: await this.venue.getRawMarketPrice() }; + } + const absNet = netQuantity < 0n ? -netQuantity : netQuantity; + const absEntry = pos.netEntryValue < 0n ? -pos.netEntryValue : pos.netEntryValue; + return { netQuantity, entryPrice: absEntry / absNet }; + } + + async getContext(): Promise { + // 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), + }; + } + + encodeCreate(intent: OrderIntent): `0x${string}` { + const qty = intent.size; + if (qty <= 0n) { + 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: createOrderAbi, + functionName: "createOrder", + args: [intent.price, this.expirationAt, signed, TimeInForce.GTC], + }); + } + + encodeUpdateOrders( + cancels: CancelIntent[], + reduces: ReduceIntent[], + creates: OrderIntent[], + ): `0x${string}` { + // Local ABI fragment until published futures-contracts includes the reduces arg. + const updateOrdersAbi = [ + { + type: "function", + name: "updateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_reduces", + type: "tuple[]", + components: [ + { name: "orderId", type: "bytes32" }, + { name: "newQuantity", type: "int256" }, + ], + }, + { + name: "_intents", + type: "tuple[]", + components: [ + { name: "price", type: "uint256" }, + { name: "expirationAt", type: "uint256" }, + { name: "quantity", type: "int256" }, + { name: "timeInForce", type: "uint8" }, + ], + }, + ], + 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) { + 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, + timeInForce: TimeInForce.GTC, + }; + }); + return encodeFunctionData({ + abi: updateOrdersAbi, + functionName: "updateOrders", + args: [cancels.map((c) => c.orderId), reduceBatch, batch], + }); + } + + encodeCancel(intent: CancelIntent): `0x${string}` { + return encodeFunctionData({ + abi: HashPowerFuturesAbi, + functionName: "cancelOrder", + args: [intent.orderId], + }); + } + + /** + * 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[]; + 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 ────────────────────────────────────────── + + private async executeOrdersImpl( + intent: ExecuteOrdersIntent, + logger: pino.Logger, + ): Promise { + 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, reduces, intent.creates); + + if (intent.dryRun) { + logger.info( + { + cancels: intent.cancels.length, + reduces: reduces.length, + creates: intent.creates.length, + }, + "DRY RUN: would send updateOrders", + ); + return { receipts: [], errors: [] }; + } + + 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, + reduces: reduces.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] }; + } + } + + /** + * 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 { + 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 { + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: HashPowerFuturesAbi, + functionName: "createOrder", + // Futures 3.0: createOrder(price, expirationAt, signedQuantity, timeInForce) + args: [1_000_000n, this.expirationAt, 1n, 0], + 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: HashPowerFuturesAbi, + functionName: "minimumPriceIncrement", + }); + this.tickCache = tick; + return tick; + } +} + +/** Per-expiry book source. Reads the ladders for this instrument's delivery date. */ +class FuturesBook implements BookSource { + private readonly inst: FuturesInstrumentAdapter; + private readonly readBatchSize: number; + constructor(inst: FuturesInstrumentAdapter, readBatchSize: number) { + this.inst = inst; + this.readBatchSize = readBatchSize; + } + + async tick(): Promise { + return this.inst.getMinTick(); + } + + async snapshot(opts: { depth?: number } = {}): Promise { + const v = this.inst.venue; + const expirationAt = this.inst.expirationAt; + const depth = BigInt(opts.depth ?? 200); + + // Same shape as perps `getOrderBookPrices(depth)`, with expirationAt first. + const [bidPrices, askPrices] = await v.publicClient.readContract({ + address: v.address, + abi: HashPowerFuturesAbi, + functionName: "getOrderBookPrices", + args: [expirationAt, depth], + }); + + if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; + + const allCalls = [ + ...bidPrices.map((p) => ({ + address: v.address, + abi: HashPowerFuturesAbi, + functionName: "getQuantityAtPrice" as const, + args: [expirationAt, p, true] as const, + })), + ...askPrices.map((p) => ({ + address: v.address, + abi: HashPowerFuturesAbi, + functionName: "getQuantityAtPrice" as const, + args: [expirationAt, p, false] as const, + })), + ]; + + const batchSize = this.readBatchSize; + 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: allResults[bidPrices.length + i], + })); + 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..20ddcfe --- /dev/null +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -0,0 +1,191 @@ +import type pino from "pino"; +import type { + OwnOrder, + OwnOrderEvent, + OwnOrderSource, + Unsubscribe, +} from "../../core/adapter.ts"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; +import type { FuturesVenueAdapter } from "./venue.ts"; +import { futuresInstrumentId } from "./events.ts"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const FUTURES_USER_ORDERS_AT_EXPIRATION_ABI = [ + { + type: "function", + name: "getUserOrdersAtExpiration", + stateMutability: "view", + inputs: [ + { name: "_user", type: "address" }, + { name: "_expirationAt", type: "uint256" }, + ], + outputs: [{ name: "orderIds", type: "bytes32[]" }], + }, +] as const; + +/** + * Cache-backed own-order source for a single futures expiry. + * + * The contract exposes a participant-order view scoped by delivery date: + * + * 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 — + * 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>(); + private readonly listeners = new Set<(event: OwnOrderEvent) => void>(); + private unsubVenue: Unsubscribe | null = null; + private bootstrapped = false; + + private readonly venue: FuturesVenueAdapter; + private readonly expirationAt: bigint; + private readonly instrumentId: string; + private readonly logger: pino.Logger; + private readonly readBatchSize: number; + + constructor( + venue: FuturesVenueAdapter, + expirationAt: bigint, + logger: pino.Logger, + readBatchSize: number, + ) { + this.venue = venue; + this.expirationAt = expirationAt; + this.instrumentId = futuresInstrumentId(expirationAt); + this.logger = logger.child({ component: "futures-own-orders" }); + this.readBatchSize = readBatchSize; + } + + 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: FUTURES_USER_ORDERS_AT_EXPIRATION_ABI, + functionName: "getUserOrdersAtExpiration", + args: [owner, this.expirationAt], + }); + + if (orderIds.length === 0) { + this.bootstrapped = true; + this.logger.info({ orders: 0 }, "futures own-orders bootstrapped (empty)"); + return; + } + + const allCalls = orderIds.map((id) => ({ + address: this.venue.address, + abi: HashPowerFuturesAbi, + functionName: "getOrder" as const, + args: [id] as const, + })); + + const batchSize = this.readBatchSize; + 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 = allOrders[i] as { + participant: string; + price: bigint; + quantity: bigint; + expirationAt: bigint; + }; + if (!o.participant || o.participant === ZERO_ADDRESS) continue; + // 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; + this.cache.set(orderIds[i], { + orderId: orderIds[i], + price: o.price, + side: o.quantity > 0n ? "buy" : "sell", + size: absQty, + instrumentId: this.instrumentId, + }); + } + + this.bootstrapped = true; + this.logger.info( + { orders: this.cache.size, expirationAt: this.expirationAt.toString() }, + "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; + // 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: 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; + 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..19bb9d5 --- /dev/null +++ b/market-maker/src/adapters/futures/venue.ts @@ -0,0 +1,474 @@ +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, +} from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.ts"; +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"; +import { depositToVault } from "../../core/vaultDeposit.ts"; +import { RawOracleReader, chainlinkAggregatorAbi } from "../../core/rawOracle.ts"; +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 `getExpirationDates()` (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; + address: `0x${string}`; + multicall3Address?: `0x${string}`; + /** Max calls per Multicall3 read batch. Default 100. */ + 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. + * + * 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}`; + readonly readBatchSize: number; + readonly writeBatchSize: number; + private readonly marketSelection: FuturesMarketSelection; + /** expirationAt → instrument, memoized so each expiry has one adapter. */ + private readonly instruments = new Map(); + /** expirationAts 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 marginPercentCache: bigint | null = null; + private imSpotShockCache: bigint | null = null; + private readonly rawOracle: RawOracleReader; + + 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.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); + + // Discover the Chainlink oracle and derive the decimal rebase on first read. + this.rawOracle = new RawOracleReader({ + publicClient: this.publicClient, + label: "futures", + resolve: async () => { + const { token } = await this.resolveAddresses(); + const oracle = await this.publicClient.readContract({ + address: this.address, + abi: HashPowerFuturesAbi, + 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( + `futures: tokenDecimals (${tokenDecimals}) > oracleDecimals (${oracleDecimals})`, + ); + } + return { + oracle, + divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + }; + }, + }); + } + + /** Nearest-expiry instrument. Back-compat / single-market entrypoint. */ + async getInstrument(): Promise { + const dates = await this.readExpirationAts(); + 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.readExpirationAts(); + 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.expirationAt.toString()), + added: added.map((i) => i.expirationAt.toString()), + dropped: dropped.map((i) => i.expirationAt.toString()), + }, + "futures markets resolved", + ); + } + return { active, added, dropped }; + } + + private instrumentFor(expirationAt: bigint): FuturesInstrumentAdapter { + const key = expirationAt.toString(); + let inst = this.instruments.get(key); + if (!inst) { + inst = new FuturesInstrumentAdapter(this, expirationAt, this.logger); + this.instruments.set(key, inst); + } + return inst; + } + + private async readExpirationAts(): Promise { + const dates = await this.publicClient.readContract({ + address: this.address, + abi: HashPowerFuturesAbi, + functionName: "getExpirationDates", + }); + 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 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, + }); + } + } + + // ── 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: HashPowerFuturesAbi, + functionName: "vault", + }, + { + address: this.address, + abi: HashPowerFuturesAbi, + functionName: "portfolioMargin", + }, + ], + }); + 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; + } + + /** + * Latest hashprice oracle answer rebased to token decimals (no tick + * rounding). See `RawOracleReader` for rationale. + */ + getRawMarketPrice(): Promise { + 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 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) { + return { marginPct: this.marginPercentCache }; + } + const liqMarginPct = await this.publicClient.readContract({ + address: this.address, + abi: HashPowerFuturesAbi, + functionName: "liquidationMarginPercent", + }); + 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; + } +} + +/** + * `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 BatchableCollateralAccount { + private readonly venue: FuturesVenueAdapter; + constructor(venue: FuturesVenueAdapter) { + this.venue = venue; + } + + /** + * 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, + * portfolioOrderMargin] + */ + async buildMarginReadPlan(): Promise { + const owner = this.venue.wallet.account.address; + const { vault, engine, token } = await this.venue.resolveAddresses(); + const mc3 = this.venue.getMulticall3Address(); + + 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] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "orderMarginOf", args: [owner] }, + ] as MarginReadPlan["shared"]; + + const venue = [ + { + address: this.venue.address, + abi: HashPowerFuturesAbi, + functionName: "getUnrealizedPnl", + 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, + portfolioOrderMargin: 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); + } + + 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 { + 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/perps/events.ts b/market-maker/src/adapters/perps/events.ts new file mode 100644 index 0000000..032f7b2 --- /dev/null +++ b/market-maker/src/adapters/perps/events.ts @@ -0,0 +1,92 @@ +import type { Log, PublicClient, WatchContractEventReturnType } from "viem"; +import type { Unsubscribe, VenueEvent, VenueEvents } from "../../core/adapter.ts"; +import { HashPowerPerpsDEXAbi } from "perps-contracts/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; + + 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); + 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/index.ts b/market-maker/src/adapters/perps/index.ts new file mode 100644 index 0000000..854a52d --- /dev/null +++ b/market-maker/src/adapters/perps/index.ts @@ -0,0 +1,37 @@ +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"; + +export interface CreatePerpsVenueOpts { + network: NetworkClients; + wallet: WalletContext; + 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; +} + +/** + * 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. + */ +export async function createPerpsVenue( + opts: CreatePerpsVenueOpts, +): Promise { + 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"; +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..d7e1703 --- /dev/null +++ b/market-maker/src/adapters/perps/instrument.ts @@ -0,0 +1,435 @@ +import { encodeFunctionData } from "viem"; +import type pino from "pino"; +import type { + BookSource, + CancelIntent, + DepthLevel, + ExecuteOrdersIntent, + ExecuteOrdersResult, + InstrumentAdapter, + InstrumentContext, + OrderBookSnapshot, + OrderIntent, + OwnOrder, + OwnOrderEvent, + OwnOrderSource, + Position, + ReduceIntent, + Unsubscribe, +} from "../../core/adapter.ts"; +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"; + +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 { + // 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 { + const owner = this.venue.wallet.account.address; + const pos = await this.venue.publicClient.readContract({ + address: this.venue.address, + 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: + absQuantity === 0n ? 0n : (absEntryValue * 1_000_000n) / absQuantity, + }; + } + + 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; + // 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: createOrderAbi, + functionName: "createOrder", + args: [intent.price, signed, TimeInForce.GTC], + }); + } + + encodeUpdateOrders( + cancels: CancelIntent[], + reduces: ReduceIntent[], + creates: OrderIntent[], + ): `0x${string}` { + // Local ABI fragment until published perps-contracts includes the reduces arg. + const updateOrdersAbi = [ + { + type: "function", + name: "updateOrders", + stateMutability: "nonpayable", + inputs: [ + { name: "_cancelIds", type: "bytes32[]" }, + { + name: "_reduces", + type: "tuple[]", + components: [ + { name: "orderId", type: "bytes32" }, + { name: "newQuantity", type: "int256" }, + ], + }, + { + name: "_intents", + type: "tuple[]", + components: [ + { name: "price", type: "uint256" }, + { name: "quantity", type: "int256" }, + { name: "timeInForce", type: "uint8" }, + ], + }, + ], + 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, + timeInForce: TimeInForce.GTC, + })); + return encodeFunctionData({ + abi: updateOrdersAbi, + functionName: "updateOrders", + args: [cancels.map((c) => c.orderId), reduceBatch, batch], + }); + } + + encodeCancel(intent: CancelIntent): `0x${string}` { + return encodeFunctionData({ + abi: HashPowerPerpsDEXAbi, + functionName: "cancelOrder", + args: [intent.orderId], + }); + } + + /** + * Execute cancels + reduces + creates via `updateOrders` (IM checked once). + */ + 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 { + 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, reduces, intent.creates); + + if (intent.dryRun) { + logger.info( + { + cancels: intent.cancels.length, + reduces: reduces.length, + creates: intent.creates.length, + }, + "DRY RUN: would send updateOrders", + ); + return { receipts: [], errors: [] }; + } + + 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, + reduces: reduces.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] }; + } + } + + /** + * Upper bound on the IM a single new resting order adds, matching the two terms + * `PortfolioMarginEngine` charges for it: + * + * 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) + * + * 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. + */ + 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 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 { + try { + return await this.venue.publicClient.estimateContractGas({ + address: this.venue.address, + abi: HashPowerPerpsDEXAbi, + functionName: "createOrder", + args: [1_000_000n, 1_000_000n, 0], + 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 { + private readonly inst: PerpsInstrumentAdapter; + constructor(inst: PerpsInstrumentAdapter) { + this.inst = inst; + } + + 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 { + private readonly inst: PerpsInstrumentAdapter; + constructor(inst: PerpsInstrumentAdapter) { + this.inst = inst; + } + + 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 || evt.participant.toLowerCase() !== own) return; + cb({ type: "removed", orderId: evt.orderId }); + return; + } + case "order-updated": { + if (evt.participant.toLowerCase() !== own) return; + if (evt.newSize === 0n) { + cb({ type: "removed", orderId: evt.orderId }); + } else { + // Price/side come from BookTracker's existing entry; patch size only. + cb({ type: "updated", orderId: evt.orderId, newSize: evt.newSize }); + } + 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/positionAbi.ts b/market-maker/src/adapters/perps/positionAbi.ts new file mode 100644 index 0000000..6baf9bd --- /dev/null +++ b/market-maker/src/adapters/perps/positionAbi.ts @@ -0,0 +1,19 @@ +/** Exact local fragment while the pinned perps ABI still exposes the legacy position tuple. */ +export const PerpsPositionAbi = [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, +] as const; diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts new file mode 100644 index 0000000..60873ab --- /dev/null +++ b/market-maker/src/adapters/perps/venue.ts @@ -0,0 +1,382 @@ +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, +} from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.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 { QUANTITY_DECIMALS } from "../../core/math.ts"; +import { + RawOracleReader, + chainlinkAggregatorAbi, +} from "../../core/rawOracle.ts"; +import { attachTenderlyUrl } from "../../core/tenderly.ts"; +import { PerpsInstrumentAdapter } from "./instrument.ts"; +import { PerpsVenueEvents } from "./events.ts"; + +export interface PerpsVenueOptions { + network: NetworkClients; + 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; +} + +/** + * 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}`; + readonly readBatchSize: number; + readonly cancelBatchSize: number; + readonly createBatchSize: number; + 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; + private readonly rawOracle: RawOracleReader; + + 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.readBatchSize = opts.readBatchSize; + this.cancelBatchSize = opts.cancelBatchSize; + this.createBatchSize = opts.createBatchSize; + + 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 { + if (!this.instrumentSingleton) { + this.instrumentSingleton = new PerpsInstrumentAdapter(this); + } + return this.instrumentSingleton; + } + + /** Perps is single-instrument; the list is always one element. */ + async listInstruments(): Promise { + 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, + }); + } + } + + // ── 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] = await this.publicClient.multicall({ + allowFailure: false, + contracts: [ + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "vault", + }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "portfolioMargin", + }, + ], + }); + // 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; + return { vault, engine, token }; + } + + async getMulticall3Address(): Promise<`0x${string}`> { + return this.multicall3Address; + } + + getLogger(): pino.Logger { + return this.logger; + } + + /** + * Latest price oracle answer rebased to token decimals (no tick rounding). + * See `RawOracleReader` for rationale. + */ + getRawMarketPrice(): Promise { + 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 + * 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(); + 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 BatchableCollateralAccount { + private readonly venue: PerpsVenueAdapter; + constructor(venue: PerpsVenueAdapter) { + this.venue = venue; + } + + /** + * 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, + * 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; + const { vault, engine, token } = await this.venue.resolveAddresses(); + const mc3 = await this.venue.getMulticall3Address(); + + 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] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "orderMarginOf", args: [owner] }, + ] as MarginReadPlan["shared"]; + + const venue = [ + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, + ] as MarginReadPlan["venue"]; + + const decode = (results: readonly unknown[]): CollateralSnapshot => { + const r = results as bigint[]; + const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; + const portfolioOrderMargin = r[5]; + const perpsUnrealizedPnl = r[6]; + const pendingFunding = r[7]; + // Funding owed (positive) reduces effective unrealized PnL. + return { + vaultBalance, + portfolioIM, + portfolioMM, + portfolioOrderMargin, + 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 { + 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..f50fe82 --- /dev/null +++ b/market-maker/src/apps/futures/config.ts @@ -0,0 +1,237 @@ +import { type Static, 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, +} from "../../core/config/base.ts"; +import { ConfigError } from "../../core/errors.ts"; + +/** + * Futures app config schema. + * + * 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. + */ +const Closed = { additionalProperties: false }; + +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.", + }), + wallet: Type.String({ + description: + "Key in the top-level `wallets` map identifying the signer for this venue.", + }), + }, + { + ...Closed, + description: "Futures venue identification and signer selection.", + }, +); + +export const futuresPricingSchema = Type.Object( + { + 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.expirationAt 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, 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. +export const futuresSizingSchema = Type.Object( + { + 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.", + }), + 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." }, +); + +export const futuresRootSchema = Type.Object( + { + 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 /health 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, + sizing: futuresSizingSchema, + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + oracle: oracleSchema, + timing: timingSchema, + health: healthSchema, + 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.", + }), + writeBatchSize: Type.Number({ + minimum: 1, + default: 100, + description: + "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." }, +); + +type RawFuturesConfig = Static; + +/** Parsed futures config: bigints/ms substituted in for human-friendly inputs. */ +export type FuturesMakerConfig = Omit< + RawFuturesConfig, + "risk" | "timing" | "collateral" | "sizing" | "oracle" +> & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; + sizing: Omit & { + baseQuantity: bigint; + }; +}; + +export function loadFuturesConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): FuturesMakerConfig { + 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), + oracle: parseOracleConfig(raw.oracle), + sizing: { + ...raw.sizing, + baseQuantity: configBigint( + String(raw.sizing.baseQuantity), + "sizing.baseQuantity", + ), + expirySizeDecay: raw.sizing.expirySizeDecay ?? 0.6, + }, + }), + 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..6a2604e --- /dev/null +++ b/market-maker/src/apps/futures/main.ts @@ -0,0 +1,192 @@ +import pino from "pino"; +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 { 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 { sanitiseConfig } from "../../core/config/base.ts"; +import { loadFuturesConfig } from "./config.ts"; + +async function main(): Promise { + 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, + readBatchSize: config.readBatchSize, + writeBatchSize: config.writeBatchSize, + 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 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, + { + ethPriceFeedAddress: + config.network.ethPriceFeed === "" + ? undefined + : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: config.risk.maxPositionSize }, + logger, + ); + const collateral = new CollateralTracker( + venue.account, + { + 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, + }, + inventory, + collateral, + gas, + oracle, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + + const quoter = new Quoter( + instrument, + { + pricing: { + strategy: "reservation-price", + riskAversion: config.pricing.riskAversion, + marginCallTimeSeconds: config.pricing.marginCallTimeSec, + minSpreadBps: config.pricing.minSpreadBps, + volatilityMultiplier: config.pricing.volatilityMultiplier, + gasPenaltyBps: config.risk.gasPenaltyBps, + }, + sizing: { + strategy: "geometric-taper", + baseQuantity: config.sizing.baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + taperRatio: config.sizing.taperRatio, + }, + maxSkewTicks: config.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, + 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, + book, + gas, + risk, + oracle, + logger, + ); + const health = new HealthCheck({ + port: config.health.port, + appName: "futures-mm", + configSummary: sanitiseConfig(config), + oracle, + inventory, + collateral, + book, + gas, + risk, + logger, + }); + + await runMakerLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, + instrument, + oracle, + gas, + book, + inventory, + collateral, + risk, + quoter, + executor, + health, + logger, + }); +} + +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..700d76e --- /dev/null +++ b/market-maker/src/apps/perps/config.ts @@ -0,0 +1,221 @@ +import { type Static, 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, +} 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 + * "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. + */ +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.", + }), + wallet: Type.String({ + description: + "Key in the top-level `wallets` map identifying the signer for this venue.", + }), + }, + { + ...Closed, + description: "Perps venue identification and signer selection.", + }, +); + +export const perpsPricingSchema = Type.Object( + { + 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, description: "Effective-spread pricing parameters." }, +); + +// `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("geometric-taper", { + description: + "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 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: "Geometric-taper sizing parameters." }, +); + +export const perpsRootSchema = Type.Object( + { + 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, + sizing: perpsSizingSchema, + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + oracle: oracleSchema, + timing: timingSchema, + health: healthSchema, + 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.", + }), + writeBatchSize: Type.Number({ + minimum: 1, + default: 100, + description: + "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." }, +); + +type RawPerpsConfig = Static; + +/** Parsed perps config: bigints/ms substituted in for human-friendly inputs. */ +export type PerpsMakerConfig = Omit< + RawPerpsConfig, + "risk" | "timing" | "collateral" | "sizing" | "oracle" +> & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; + sizing: Omit & { + baseQuantity: bigint; + }; +}; + +export function loadPerpsConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): PerpsMakerConfig { + 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), + oracle: parseOracleConfig(raw.oracle), + 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 new file mode 100644 index 0000000..4ef60e3 --- /dev/null +++ b/market-maker/src/apps/perps/main.ts @@ -0,0 +1,187 @@ +import pino from "pino"; +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 { 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 { 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"; + +async function main(): Promise { + 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, + readBatchSize: config.readBatchSize, + cancelBatchSize: config.writeBatchSize, + createBatchSize: config.writeBatchSize, + logger, + }); + const instrument = await venue.getInstrument(); + + 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, + { + ethPriceFeedAddress: + config.network.ethPriceFeed === "" + ? undefined + : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: config.risk.maxPositionSize }, + logger, + ); + const collateral = new CollateralTracker( + venue.account, + { + 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, + }, + inventory, + collateral, + gas, + oracle, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + + 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: "geometric-taper", + baseQuantity: config.sizing.baseQuantity, + numLevelsPerSide: config.sizing.numLevelsPerSide, + taperRatio: config.sizing.taperRatio, + }, + maxSkewTicks: config.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, + staleBandAllowance: config.timing.staleBandAllowance, + staleSizeAllowance: config.timing.staleSizeAllowance, + quantityScale: QUANTITY_SCALE, + 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: sanitiseConfig(config), + oracle, + inventory, + collateral, + book, + gas, + risk, + logger, + }); + + await runMakerLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, + instrument, + oracle, + gas, + book, + inventory, + collateral, + risk, + quoter, + executor, + health, + logger, + }); +} + +main(); diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts new file mode 100644 index 0000000..bd7dd44 --- /dev/null +++ b/market-maker/src/apps/portfolio/config.ts @@ -0,0 +1,364 @@ +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( + { + 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.", + }), + 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." }, +); + +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: 100 }), + }, + { ...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 { + confirmationTimeoutMs: number; + maxReplacements: number; + replacementFeeBumpPct: number; + maxNonceResyncs: 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"), + // Default may be stripped from the venues union schema (AJV combinator rule). + expirySizeDecay: raw.sizing.expirySizeDecay ?? 0.6, + }, + }; +} + +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 ?? { + confirmationTimeoutSec: 60, + maxReplacements: 2, + replacementFeeBumpPct: 15, + maxNonceResyncs: 5, + }; + 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: { + confirmationTimeoutMs: secondsToMs(tx.confirmationTimeoutSec, "txCoordinator.confirmationTimeoutSec"), + maxReplacements: tx.maxReplacements, + replacementFeeBumpPct: tx.replacementFeeBumpPct, + maxNonceResyncs: tx.maxNonceResyncs ?? 5, + }, + 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..29a70b9 --- /dev/null +++ b/market-maker/src/apps/portfolio/main.ts @@ -0,0 +1,361 @@ +import pino from "pino"; +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"; +import { expirySizeScale } from "../../core/sizing/expiryDecay.ts"; +import { QUANTITY_SCALE } from "../../core/math.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"] { + const s = venue.sizing; + return { + strategy: "geometric-taper", + baseQuantity: s.baseQuantity, + numLevelsPerSide: s.numLevelsPerSide, + taperRatio: s.taperRatio, + }; +} + +/** + * 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); + 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, + ); + if (venue.kind === "futures" && opts.expiryIndex !== undefined) { + quoter.setSizeScale( + expirySizeScale(opts.expiryIndex, venue.sizing.expirySizeDecay ?? 0.6), + ); + } + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + staleBandAllowance: config.timing.staleBandAllowance, + staleSizeAllowance: config.timing.staleSizeAllowance, + quantityScale: venue.kind === "futures" ? 1n : QUANTITY_SCALE, + 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 { + 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(); // 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 ─────────────────────────────────────────────── + const nonce = new NonceManager( + network.publicClient, + wallet.walletClient, + wallet.account, + network.chain, + { + confirmationTimeoutMs: config.txCoordinator.confirmationTimeoutMs, + maxReplacements: config.txCoordinator.maxReplacements, + replacementFeeBumpPct: config.txCoordinator.replacementFeeBumpPct, + maxNonceResyncs: config.txCoordinator.maxNonceResyncs, + }, + logger, + ); + const coordinator = new TxCoordinator( + nonce, + {}, + 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 (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, { + expiryIndex: indexById.get(inst.id) ?? 0, + }), + ), + 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 new file mode 100644 index 0000000..71bfb68 --- /dev/null +++ b/market-maker/src/core/adapter.ts @@ -0,0 +1,430 @@ +import type { + Account, + Chain, + ContractFunctionParameters, + 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"; + +/** + * 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 + * counts cast to bigint). Each adapter validates the unit internally. + */ +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 { + orderId: `0x${string}`; +} + +/** + * 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; + /** 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. */ +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}`; + /** Size-only patch when full `order` is unavailable (e.g. perps OrderUpdated). */ + newSize?: bigint; +} + +/** 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; + /** + * `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; + 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; +} + +/** + * 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. */ + expirationAt?: number; + /** Strike price (options). */ + strike?: bigint; + /** Call vs put (options). */ + isCall?: boolean; + /** Underlying spot (options). */ + underlyingSpot?: bigint; +} + +// ─── Order book ───────────────────────────────────────────────────────────── + +export interface BookSource { + /** 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; + /** Futures expiry (unix seconds) the order belongs to; undefined for perps. */ + expirationAt?: bigint; + } + | { + 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}`; + + /** + * 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}`; + + /** + * Execute a batch of order cancellations, reduces, and creations on-chain. + * + * Cancels → reduces → creates in one `updateOrders` call (one IM check). + */ + 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 + * `engine.canPlaceOrder(wallet, sumAdditionalIM)` before placing. + * + * 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 and the last + * mark). 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, 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. + */ +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 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; + + /** + * 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}`>; +} diff --git a/market-maker/src/core/bookTracker.ts b/market-maker/src/core/bookTracker.ts new file mode 100644 index 0000000..129f88c --- /dev/null +++ b/market-maker/src/core/bookTracker.ts @@ -0,0 +1,121 @@ +import type pino from "pino"; +import type { InstrumentAdapter, OwnOrder, Unsubscribe } from "./adapter.ts"; + +export interface BookTrackerConfig { + /** Periodic full resync interval (ms). */ + 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.book.snapshot()` and `instrument.ownOrders.list()` + * - live updates via `instrument.ownOrders.subscribe()` (own-order delta only) + * + * 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. Mirrors `instrument.ownOrders` for cheap reads. */ + 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 unsubOwn: 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.subscribeOwn(); + } + + stop(): void { + this.unsubOwn?.(); + this.unsubOwn = 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.book.snapshot({ depth: this.cfg.snapshotDepth ?? 200 }), + this.instrument.ownOrders.list(), + ]); + + 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 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); + } 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/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/client.ts b/market-maker/src/core/client.ts new file mode 100644 index 0000000..2d91456 --- /dev/null +++ b/market-maker/src/core/client.ts @@ -0,0 +1,59 @@ +import { createPublicClient, createWalletClient, defineChain, http, webSocket } from "viem"; +import type { Account, Chain, Hex, PublicClient, Transport, WalletClient } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +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/core/collateralTracker.ts b/market-maker/src/core/collateralTracker.ts new file mode 100644 index 0000000..fe86848 --- /dev/null +++ b/market-maker/src/core/collateralTracker.ts @@ -0,0 +1,132 @@ +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; + /** 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; +} + +/** + * 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; + portfolioOrderMargin = 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; + // Trigger gate: dust filter so we don't pay gas on a tiny sweep. + if (this.walletTokenBalance < this.cfg.autoDepositMinAmount) return; + // 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(); + } + + /** 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.portfolioOrderMargin = s.portfolioOrderMargin; + 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..70905e9 --- /dev/null +++ b/market-maker/src/core/config/base.ts @@ -0,0 +1,546 @@ +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 type { Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +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 }) => { + 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. + * + * 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({ description: "Hex-encoded ECDSA private key for the signer." }), + }, + { ...Closed, description: "Named signer wallet. Referenced by venue.wallet." }, +); + +export const networkSchema = Type.Object( + { + 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()], { + description: + "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", + }), + ), + }, + { ...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: 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, description: "Risk caps, circuit-breakers, and gas-price guards." }, +); + +export const gasSchema = Type.Object( + { + 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, 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( + { + pollIntervalSec: TypeSeconds({ + minimum: 0.1, + default: 3, + description: "Seconds between main-loop iterations (snapshot, quote, execute).", + }), + 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.", + }), + 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: 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).", + }), + }, + { ...Closed, description: "Loop cadences and requote thresholds." }, +); + +export const collateralSchema = Type.Object( + { + 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, description: "Collateral vault behaviour." }, +); + +export const healthSchema = Type.Object( + { + port: Type.Number({ + minimum: 0, + default: 3001, + description: "TCP port for the /healthz HTTP endpoint.", + }), + }, + { ...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 + * 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; +} + +/** + * 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; + 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 { + 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; +} + +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; + minCollateralBalance: string | number; + maxDailyLossUsd: string | number; + maxGasBudgetPerHourUsd: string | number; + maxGasBudgetPerDayUsd: string | number; + gasSpikeThresholdPct: number; + gasPenaltyBps: number; + urgentRequoteThresholdTicks: number; +} +interface RawTiming { + pollIntervalSec: string | number; + requoteCooldownSec: string | number; + resyncIntervalSec: string | number; + levelSpacingTicks: number; + staleBandAllowanceUsd?: string | number; + staleSizeAllowanceUsd?: string | number; +} +interface RawCollateral { + autoDeposit: boolean; + autoDepositMinAmount: string | number; + maxCollateralAmount?: string | number; +} +interface RawOracle { + windowSize: number; + precisionBits: number; + historyLookbackMultiplier: number; + history?: { subgraphUrl: string }; +} + +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"), + 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 ?? 50, + USD_DECIMALS, + "timing.staleSizeAllowanceUsd", + ), + }; +} + +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 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; + env?: NodeJS.ProcessEnv; + /** 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, +): TParsed { + const env = opts.env ?? process.env; + // Precedence: explicit opts.path > --config CLI arg > MAKER_CONFIG env var. + 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 = opts.parse(expanded as TRaw); + 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}"`); + } +} + +/** + * 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/config/units.ts b/market-maker/src/core/config/units.ts new file mode 100644 index 0000000..3273239 --- /dev/null +++ b/market-maker/src/core/config/units.ts @@ -0,0 +1,87 @@ +// 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}`); +} + +/** + * 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" + * → 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/errSerializer.ts b/market-maker/src/core/errSerializer.ts new file mode 100644 index 0000000..e5f9c0e --- /dev/null +++ b/market-maker/src/core/errSerializer.ts @@ -0,0 +1,107 @@ +import type { ErrorInfo } from "./errors.ts"; + +/** + * 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; + } +} + +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; +} + +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/core/errors.ts b/market-maker/src/core/errors.ts new file mode 100644 index 0000000..c2cdc91 --- /dev/null +++ b/market-maker/src/core/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/core/gasTracker.ts b/market-maker/src/core/gasTracker.ts new file mode 100644 index 0000000..76a4ca9 --- /dev/null +++ b/market-maker/src/core/gasTracker.ts @@ -0,0 +1,155 @@ +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; + medianGasPrice = 0n; + 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. */ + 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; + 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/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 new file mode 100644 index 0000000..99c422e --- /dev/null +++ b/market-maker/src/core/healthcheck.ts @@ -0,0 +1,357 @@ +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 { 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; + ordersCancelled: number; + reconcileCount: number; +} + +export interface HealthCheckOptions { + port: number; + appName: string; + /** + * 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; + collateral: CollateralTracker; + book: BookTracker; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; +} + +/** + * HTTP endpoint exposing health, status, and runtime config. + * + * 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; + 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 opts: HealthCheckOptions; + + constructor(opts: HealthCheckOptions) { + 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.handleHealthHuman(res); + if (req.method === "GET" && req.url === "/health/raw") + return this.handleHealthRaw(res); + res.writeHead(404); + res.end(); + } catch (err) { + this.opts.logger.error({ err }, "server error"); + res.writeHead(500); + res.end(); + } + }); + + const logger = this.opts.logger; + const port = this.opts.port; + this.server.listen(port, () => { + logger.info( + { + human: `http://localhost:${port}/health`, + raw: `http://localhost:${port}/health/raw`, + }, + "health endpoints 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 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), + portfolioOrderMarginUsdc: formatUsdcAmount(collateral.portfolioOrderMargin), + 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( + { + 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(), + volatilityPerSecond: fractionToNumber(oracle.volatilityPerSecond), + bestBid: book.bestBid.toString(), + bestAsk: book.bestAsk.toString(), + ownOrders: serializeOwnOrders(book.ownOrders), + }, + inventory: { + netPosition: inventory.netQuantity.toString(), + inventorySkew: fractionToNumber(inventory.inventorySkew), + }, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + portfolioOrderMargin: collateral.portfolioOrderMargin.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) { + this.respondOk(res); + return; + } + this.paused = true; + this.status = "stopped"; + this.lastError = null; + + if (!this.onStop) { + this.respondOk(res); + return; + } + 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 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) { + this.respondOk(res); + return; + } + this.paused = false; + this.status = "running"; + this.lastError = null; + + if (!this.onStart) { + this.respondOk(res); + return; + } + 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" })); + }); + } +} + +function fractionToNumber(value: Fraction): number { + // 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<{ price: bigint; quantity: bigint; orderIds: `0x${string}`[] }>; + asks: Array<{ price: bigint; quantity: bigint; orderIds: `0x${string}`[] }>; +} + +/** + * 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 { + // 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()) { + 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] }); + } + } + + 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: bidEntries.map(([price, v]) => ({ + price, + quantity: v.quantity, + orderIds: v.orderIds, + })), + asks: askEntries.map(([price, v]) => ({ + price, + quantity: v.quantity, + orderIds: v.orderIds, + })), + }; +} diff --git a/market-maker/src/core/helpers.ts b/market-maker/src/core/helpers.ts new file mode 100644 index 0000000..1202db3 --- /dev/null +++ b/market-maker/src/core/helpers.ts @@ -0,0 +1,142 @@ +/** + * 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 ──────────────────────────────────────────────────────────── + +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. + * 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. */ + 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). + */ +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. + */ +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 }; + 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/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/inventoryManager.ts b/market-maker/src/core/inventoryManager.ts new file mode 100644 index 0000000..d499ae9 --- /dev/null +++ b/market-maker/src/core/inventoryManager.ts @@ -0,0 +1,72 @@ +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); + } + + /** 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..63729c4 --- /dev/null +++ b/market-maker/src/core/marketRuntime.ts @@ -0,0 +1,180 @@ +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 })), + reduces: planned.reduces, + 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 new file mode 100644 index 0000000..26ec540 --- /dev/null +++ b/market-maker/src/core/math.ts @@ -0,0 +1,261 @@ +/** + * 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"; + +// 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; + +/** 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 / QUANTITY_SCALE. */ +export function calculateNotional(price: bigint, absQuantity: bigint): bigint { + const q = bigAbs(absQuantity); + 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). + * + * 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; +} + +/** Absolute value for bigint. */ +export function bigAbs(v: bigint): bigint { + return v < 0n ? -v : v; +} + +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: + * - 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) + * + * # 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 + * 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. + * - 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; + + constructor(maxSize: number, precisionBits = 64) { + this.maxSize = maxSize; + this.precisionBits = precisionBits; + } + + /** + * 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(); + } + } + + get length(): number { + return this.samples.length; + } + + latest(): bigint | undefined { + return this.samples.length > 0 ? this.samples[this.samples.length - 1] : undefined; + } + + /** 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); + + 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) { + const ratio = new Fraction(curr, prev); + returns.push(ln(ratio, this.precisionBits)); + } + } + + if (returns.length < 2) return new Fraction(0n); + return sampleStddev(returns, this.precisionBits); + } + + /** + * 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); + + 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); + } + + if (xs.length < 2) return new Fraction(0n); + return sampleStddev(xs, 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; + } +} + +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). + */ +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/core/nonceManager.ts b/market-maker/src/core/nonceManager.ts new file mode 100644 index 0000000..48240b8 --- /dev/null +++ b/market-maker/src/core/nonceManager.ts @@ -0,0 +1,285 @@ +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; + /** + * 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. */ +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 maxNonceResyncs: 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.maxNonceResyncs = cfg.maxNonceResyncs ?? 5; + 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 { + let nonce = await this.nextNonce(); + let fee = opts.maxFeePerGas; + let resyncs = 0; + + 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 *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. + 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"); + } + } +} + +/** + * 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/src/core/oracleTracker.ts b/market-maker/src/core/oracleTracker.ts new file mode 100644 index 0000000..aa7d995 --- /dev/null +++ b/market-maker/src/core/oracleTracker.ts @@ -0,0 +1,155 @@ +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 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.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 && price !== this.lastSampledPrice) { + this.priceWindow.push(price, this.nowSec()); + this.lastSampledPrice = price; + this.volatilityPerSecond = this.priceWindow.volatilityPerSecond(); + } + this.logger.debug( + { + price: price.toString(), + volatilityPerSec: this.volatilityPerSecond.valueOf(), + windowFill: this.priceWindow.length, + }, + "oracle tick", + ); + } +} diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts new file mode 100644 index 0000000..2bd0aea --- /dev/null +++ b/market-maker/src/core/orderExecutor.ts @@ -0,0 +1,461 @@ +import type pino from "pino"; +import type { + InstrumentAdapter, + OrderIntent, + OwnOrder, + ReduceIntent, + Side, +} 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, notionalToSize } from "./math.ts"; + +export interface OrderExecutorConfig { + /** Skip a requote if elapsed since last < cooldown (ms). */ + requoteCooldownMs: number; + /** 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. + * + * 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`. 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 }; + + 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, + }); + } + + /** + * 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[]; + reduces: ReduceIntent[]; + creates: OrderIntent[]; + } | null { + if (!this.shouldRequote(desired)) return null; + + 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 null; + } + this.logger.warn({ drift }, "proceeding with requote despite gas spike"); + } + + 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, reduces, 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. + 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 && planned.reduces.length === 0 && places.length === 0) { + return; + } + + 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, + }); + + for (const receipt of result.receipts) { + this.risk.recordGasCost(this.computeTxGasCost(receipt)); + } + + this.stats.ordersCancelled += ordersToCancel.length; + this.stats.ordersPlaced += places.length; + + 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 result = await this.instrument.executeOrders({ + cancels: orders.map((o) => ({ orderId: o.orderId })), + creates: [], + 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)); + } + + 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 { + const elapsed = Date.now() - this.lastRequoteAt; + const cooldownMs = this.effectiveCooldownMs(); + if (elapsed < cooldownMs) { + this.logger.debug( + { elapsedMs: elapsed, cooldownMs }, + "requote skipped: cooldown", + ); + return false; + } + + // 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( + { actualCount, expectedCount }, + "requote triggered: order count deficit", + ); + return true; + } + + if (this.hasQuantityDeficit(desired)) { + this.logger.debug("requote triggered: quantity deficit"); + return true; + } + + 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; + } + + this.logger.debug( + { 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(); + 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; + } + + /** + * 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 + * - within-allowance worse off-grid → keep + */ + private findStaleOrders(desired: OrderIntent[]): { + cancels: OwnOrder[]; + reduces: ReduceIntent[]; + } { + 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); + desiredSize.set(k, (desiredSize.get(k) ?? 0n) + i.size); + if (i.side === "buy") { + if (worstDesiredBid === undefined || i.price < worstDesiredBid) { + worstDesiredBid = 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; + } + } + } + + 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; + } + // 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); + const list = byKey.get(k); + if (list) list.push(order); + else byKey.set(k, [order]); + } + + for (const [k, orders] of byKey) { + const want = desiredSize.get(k); + // 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; + 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) { + cancels.push(o); + excess -= o.size; + } else { + reduces.push({ + orderId: o.orderId, + newSize: o.size - excess, + side: o.side, + }); + excess = 0n; + } + } + } + return { cancels, reduces }; + } + + /** + * 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[], + 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; + const deficit = i.size - have; + if (deficit > 0n && this.sizeDeltaAboveThreshold(i.price, deficit)) { + out.push({ side: i.side, price: i.price, size: deficit }); + } + } + 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)) ?? 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[] = [], + ): 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) + size); + } + return m; + } + + /** + * 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 + ); + } +} + +function keyOf(side: Side, price: bigint): string { + return `${side}@${price.toString()}`; +} diff --git a/market-maker/src/core/portfolioCollateral.ts b/market-maker/src/core/portfolioCollateral.ts new file mode 100644 index 0000000..b0eae1b --- /dev/null +++ b/market-maker/src/core/portfolioCollateral.ts @@ -0,0 +1,93 @@ +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 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; + venueUnrealizedPnl += snap.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 venueUnrealizedPnl = 0n; + for (const s of snaps) { + venueUnrealizedPnl += s.venueUnrealizedPnl; + } + return { ...primary, 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..dc38414 --- /dev/null +++ b/market-maker/src/core/portfolioHealth.ts @@ -0,0 +1,254 @@ +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"; +import { + formatAgeMs, + formatDurationSec, + formatEthAmount, + formatPrice, + formatTimestampMs, + formatUsdcAmount, +} from "./healthFormat.ts"; + +export interface PortfolioHealthOptions { + port: number; + appName: string; + configSummary: Record; + collateral: CollateralTracker; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; +} + +/** + * 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; + 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.handleHealthHuman(res); + if (req.method === "GET" && req.url === "/health/raw") return this.handleHealthRaw(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( + { + human: `http://localhost:${port}/health`, + raw: `http://localhost:${port}/health/raw`, + }, + "health endpoints 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(); + }); + }); + } + + /** 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), + portfolioOrderMarginUsdc: formatUsdcAmount(collateral.portfolioOrderMargin), + 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( + { + 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(), + portfolioOrderMargin: collateral.portfolioOrderMargin.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, + ); + this.respondJson(res, body); + } + + private handleStop(res: ServerResponse): void { + if (this.paused) { + this.respondOk(res); + return; + } + this.paused = true; + this.status = "stopped"; + this.lastError = null; + if (!this.onStop) { + this.respondOk(res); + return; + } + 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) { + this.respondOk(res); + return; + } + this.paused = false; + this.status = "running"; + this.lastError = null; + if (!this.onStart) { + this.respondOk(res); + return; + } + 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 { + 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(body); + } +} + +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..fde7d2e --- /dev/null +++ b/market-maker/src/core/portfolioRunner.ts @@ -0,0 +1,365 @@ +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.reduces?.length ?? 0) > 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/pricing/effectiveSpread.ts b/market-maker/src/core/pricing/effectiveSpread.ts new file mode 100644 index 0000000..d31ffaf --- /dev/null +++ b/market-maker/src/core/pricing/effectiveSpread.ts @@ -0,0 +1,196 @@ +/** + * # 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 * σ_s * √H_sec * 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) + * - σ_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), σ_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 = 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 + * 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"; +import type { InventoryManager } from "../inventoryManager.ts"; +import { BPS_SCALE, calculateNotional } from "../math.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. */ + 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; +} + +/** See file header for full formula and worked example. */ +export function computeMidQuote(opts: { + oracle: OracleTracker; + gas: GasTracker; + inventory: InventoryManager; + cfg: EffectiveSpreadConfig; + 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, volHorizonSec } = opts; + const oraclePrice = oracle.currentPrice; + + const spreadBps = effectiveSpreadBps({ oracle, gas, inventory, cfg, baseQuantity, volHorizonSec }); + 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; + volHorizonSec: number; +}): Fraction { + 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; + + // σ_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)); + + const skewAbs = inventory.inventorySkew.abs(); + 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 + ? spike.div(new Fraction(100n)).mul(new Fraction(cfg.gasPenaltyBps)) + : new Fraction(0n); + + 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); + 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"); +} + +/** + * √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 new file mode 100644 index 0000000..bed62a2 --- /dev/null +++ b/market-maker/src/core/pricing/reservationPrice.ts @@ -0,0 +1,161 @@ +/** + * # 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 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 + * + * 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 = σ_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, expirationAt − 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) + * - σ_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 + * + * 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, σ_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. + * + * σ_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 + * + * - 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 expirationAt (margin-call point); + * after delivery the position settles and there's no more inventory risk. + */ + +import Fraction from "fraction.js"; +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"; +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; + /** 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; + /** 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; + /** Holding-time horizon (seconds) used to scale per-second σ into bps. */ + volHorizonSec: number; + nowMs?: number; +}): MidQuote { + const { oracle, gas, inventory, context, cfg, tick, volHorizonSec, nowMs = Date.now() } = opts; + const S = oracle.currentPrice; + + // Reservation price r = S − q·γ·σ_s²·T (file header). + const sigma = oracle.volatilityPerSecond; + const sigma2 = sigma.mul(sigma); + const gamma = fromNumber(cfg.riskAversion); + + 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); + 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 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; + 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; + volHorizonSec: number; +}): Fraction { + const { oracle, gas, cfg, volHorizonSec } = opts; + + const minSpread = fromNumber(cfg.minSpreadBps / 2); // half of the full-spread floor + const horizonScale = horizonStddevScale(volHorizonSec); + const volBps = oracle.volatilityPerSecond + .mul(horizonScale) + .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); +} + +/** √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 new file mode 100644 index 0000000..3651508 --- /dev/null +++ b/market-maker/src/core/quoter.ts @@ -0,0 +1,231 @@ +import type pino from "pino"; +import type Fraction from "fraction.js"; +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"; +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"; +import { scaleBaseQuantity } from "./sizing/expiryDecay.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; + /** + * Spacing between successive quote levels, in ticks. App-defaulted per + * venue: futures wants narrow spacing (dense ladder), perps wants wider + * spacing (deeper levels only fill after shallower ones). + */ + 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; +} + +/** + * 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; + 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; + 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 }); + } + + /** 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(); + this.logger.info( + { tick: this.tick.toString(), expirationAt: this.context.expirationAt }, + "quoter initialized", + ); + } + + getTick(): bigint { + return this.tick; + } + + getContext(): InstrumentContext { + return this.context; + } + + computeQuotes(): OrderIntent[] { + const oraclePrice = this.oracle.currentPrice; + if (oraclePrice === 0n || this.tick === 0n) { + return []; + } + + 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, + volHorizonSec: this.cfg.volHorizonSec, + }) + : 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, + volHorizonSec: this.cfg.volHorizonSec, + }); + + const { bidMid, askMid, spreadBps } = midQuote; + const { quoteBid, quoteAsk } = this.risk.allowedSides( + this.inventory, + this.inventory.maxPositionSize, + ); + + const intents: OrderIntent[] = []; + const spacing = BigInt(this.cfg.levelSpacingTicks) * this.tick; + + for (let level = 0; level < sizes.length; level++) { + const offset = BigInt(level) * spacing; + const size = sizes[level]; + if (size <= 0n) continue; + + if (quoteBid) { + const bidRaw = bidMid - offset; + const bidPrice = roundDownToTick(bidRaw > 0n ? bidRaw : this.tick, this.tick); + intents.push({ side: "buy", price: bidPrice, size }); + } + + if (quoteAsk) { + const askRaw = askMid + offset; + const askPrice = roundUpToTick(askRaw, this.tick); + if (askPrice > 0n) intents.push({ side: "sell", price: askPrice, size }); + } + } + + // Guaranteed one-tick wide book after rounding (0-spread can lock bid==ask). + this.widenLockedBook(intents); + + this.logger.debug( + { + strategy: this.cfg.pricing.strategy, + spreadBps: fractionToString(spreadBps), + bids: intents.filter((i) => i.side === "buy").length, + asks: intents.filter((i) => i.side === "sell").length, + }, + "quotes computed", + ); + + 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); + if (s.strategy === "linear") { + return linearSizes(base, s.numLevelsPerSide); + } + return geometricTaperSizes( + base * BigInt(s.numLevelsPerSide), + s.taperRatio, + s.numLevelsPerSide, + ); + } +} + +/** Diagnostic-only Fraction → string. Never used in trading math. */ +function fractionToString(f: Fraction): string { + 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/core/rational.ts b/market-maker/src/core/rational.ts new file mode 100644 index 0000000..56cbcc0 --- /dev/null +++ b/market-maker/src/core/rational.ts @@ -0,0 +1,171 @@ +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; +} + +/** + * 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); + 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 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"); + } + + let { n, d } = x; + let k = 0n; + while (n >= 2n * d) { + d *= 2n; + k += 1n; + } + while (d > 2n * n) { + n *= 2n; + k -= 1n; + } + const y = new Fraction(n, d); + + const lnY = atanhSeries(y, precisionBits); + if (k === 0n) return lnY; + + const ln2 = ln2Cached(precisionBits); + return lnY.add(ln2.mul(new Fraction(k))); +} + +/** + * 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)); + if (u.n === 0n) return new Fraction(0n); + + const u2 = u.mul(u); + let term = u; + let sum = term; + const tolDen = 1n << BigInt(precisionBits); + const tolerance = new Fraction(1n, tolDen); + + let i = 1n; + while (true) { + 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 stays in [1/2, 2] with k=0. + 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. + * + * `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); + } + const q = floorDiv(num, den); + const r = num - q * 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. */ +export { default as Fraction } from "fraction.js"; diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts new file mode 100644 index 0000000..c2cc940 --- /dev/null +++ b/market-maker/src/core/rawOracle.ts @@ -0,0 +1,108 @@ +/** + * 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 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) + * 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"; + +/** 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; + private lastAnswer: bigint | 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()})`); + } + // Mirror the venue's `getMarketPrice()` decimal rebase (no unit factor). + 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. */ + invalidate(): void { + this.cache = null; + } +} diff --git a/market-maker/src/core/riskManager.ts b/market-maker/src/core/riskManager.ts new file mode 100644 index 0000000..6c06be2 --- /dev/null +++ b/market-maker/src/core/riskManager.ts @@ -0,0 +1,246 @@ +/** + * # 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"; +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; +} + +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; + /** + * 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; + private readonly logger: pino.Logger; + + constructor( + cfg: RiskManagerConfig, + inventory: InventoryManager | null, + 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" }); + this.gasHourlyBudget = new RollingBudget(60 * 60 * 1000); + this.gasDailyBudget = new RollingBudget(24 * 60 * 60 * 1000); + } + + /** Snapshot starting collateral; call once after first collateral update. */ + initialize(): void { + this.startOfDayBalance = this.collateral.vaultBalance; + 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.collateral.vaultBalance < this.cfg.minCollateralBalance) { + return this.halt({ + message: "collateral below minimum", + balance: this.collateral.vaultBalance.toString(), + min: this.cfg.minCollateralBalance.toString(), + }); + } + + // 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) { + return this.halt({ + message: "daily loss limit breached", + pnl: truePnl.toString(), + max: this.cfg.maxDailyLossUsd.toString(), + }); + } + + 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; + } + + /** + * 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 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( + 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 }; + if (net < 0n) return { quoteBid: true, quoteAsk: false }; + return { quoteBid: false, quoteAsk: false }; + } + + return { + quoteBid: net < maxPos, + quoteAsk: net > -maxPos, + }; + } + + 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.collateral.vaultBalance - 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.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..5d0b450 --- /dev/null +++ b/market-maker/src/core/runner.ts @@ -0,0 +1,216 @@ +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; + /** + * 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; + 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 cancelOrdersOnShutdown = opts.cancelOrdersOnShutdown ?? true; + 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.initialize(); + 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.initialize(); + 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({ 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(); + 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, + }, + "main loop 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/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/src/core/sizing/geometricTaper.ts b/market-maker/src/core/sizing/geometricTaper.ts new file mode 100644 index 0000000..52bb47f --- /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 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 + * + * 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/core/sizing/linear.ts b/market-maker/src/core/sizing/linear.ts new file mode 100644 index 0000000..5afc8d9 --- /dev/null +++ b/market-maker/src/core/sizing/linear.ts @@ -0,0 +1,16 @@ +/** + * 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[] = []; + for (let k = 0; k < numLevels; k++) { + out.push(baseQuantity * BigInt(k + 1)); + } + return out; +} 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/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts new file mode 100644 index 0000000..267eeb6 --- /dev/null +++ b/market-maker/src/core/txCoordinator.ts @@ -0,0 +1,166 @@ +import type pino from "pino"; +import type { + CancelIntent, + InstrumentAdapter, + OrderIntent, + ReduceIntent, + 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[]; + reduces: ReduceIntent[]; + creates: OrderIntent[]; +} + +export type TxCoordinatorConfig = Record; + +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; + ordersReduced: 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 — all futures expiries merge into one + * `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 logger: pino.Logger; + + constructor(nonce: NonceManager, _cfg: TxCoordinatorConfig, logger: pino.Logger) { + this.nonce = nonce; + this.logger = logger.child({ component: "tx-coordinator" }); + } + + async submit(all: MarketIntents[], opts: SubmitOptions): Promise { + const result: SubmitResult = { + receipts: [], + errors: [], + ordersPlaced: 0, + ordersCancelled: 0, + ordersReduced: 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/reducing 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 encoder = markets[0]?.instrument; + 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) { + creates.push(expiry !== undefined ? { ...c, expirationAt: c.expirationAt ?? expiry } : c); + } + } + + if (cancels.length === 0 && reduces.length === 0 && creates.length === 0) continue; + + 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, + }, + "DRY RUN: would submit venue updateOrders", + ); + result.ordersCancelled += cancels.length; + result.ordersReduced += reduces.length; + result.ordersPlaced += creates.length; + continue; + } + + try { + 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)); + result.errors.push(wrapped); + this.logger.error( + { err: wrapped, venue: venue.kind }, + "venue submission failed — other venues unaffected", + ); + } + } + + return result; + } +} + +function instrumentExpirationAt(instrument: InstrumentAdapter): bigint | undefined { + const expiry = (instrument as { expirationAt?: unknown }).expirationAt; + return typeof expiry === "bigint" ? expiry : undefined; +} diff --git a/market-maker/src/core/vaultDeposit.ts b/market-maker/src/core/vaultDeposit.ts new file mode 100644 index 0000000..b72cf57 --- /dev/null +++ b/market-maker/src/core/vaultDeposit.ts @@ -0,0 +1,257 @@ +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, + }; + console.log("domain", domain); + } 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 { + console.log("args", [owner, amount, deadline, v, r, s]); + 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/core/wallet.ts b/market-maker/src/core/wallet.ts new file mode 100644 index 0000000..44a9298 --- /dev/null +++ b/market-maker/src/core/wallet.ts @@ -0,0 +1,37 @@ +import type { Chain, Hex, Transport } from "viem"; +import { ConfigError } from "./errors.ts"; +import { createWalletFromKey } from "./client.ts"; +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. + */ +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..1fe6553 --- /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", + ETHEREUM_RPC_URL: "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..685d670 --- /dev/null +++ b/market-maker/tests-pending/market-maker.e2e.test.ts @@ -0,0 +1,536 @@ +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"; +import { TimeInForce } from "../src/core/adapter.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, number], opts: unknown) => Promise }; + } + ).write.createOrder([bestAsk.price, takerQty, TimeInForce.GTC], { + 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, number], opts: unknown) => Promise }; + } + ).write.createOrder([bestAsk.price, takerQty, TimeInForce.GTC], { + 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..0976b77 --- /dev/null +++ b/market-maker/tests-pending/market-maker.process.test.ts @@ -0,0 +1,661 @@ +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 { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; +import { + startHardhatNode, + waitFor, + sleep, + createTestPublicClient, + createTestWalletClient, + createTestClientInstance, + HARDHAT_ACCOUNTS, + 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]; +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", + 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), + 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), + TimeInForce.GTC, + ]); + + // 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, TimeInForce.GTC]); + + 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.pmeAddress, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioMM", + 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, TimeInForce.GTC]); + + 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.pmeAddress, + abi: PortfolioMarginEngineAbi, + 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/apps/futures/config.test.ts b/market-maker/tests/apps/futures/config.test.ts new file mode 100644 index 0000000..963a870 --- /dev/null +++ b/market-maker/tests/apps/futures/config.test.ts @@ -0,0 +1,136 @@ +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 + 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-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 + marginCallTimeSec: 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 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/); + }); + + 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..c609ca5 --- /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", + 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"); + 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/apps/perps/config.test.ts b/market-maker/tests/apps/perps/config.test.ts new file mode 100644 index 0000000..a599c52 --- /dev/null +++ b/market-maker/tests/apps/perps/config.test.ts @@ -0,0 +1,156 @@ +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 { loadPerpsConfig } from "../../../src/apps/perps/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: geometric-taper + baseQuantity: "1000000" + numLevelsPerSide: 5 + taperRatio: 0.6 +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 +gas: + gasCapMultiplier: 2.0 +timing: + pollIntervalSec: 3 +collateral: {} +oracle: {} +health: + port: 8080 +`; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mm-perps-cfg-")); +}); + +afterEach(() => { + try { unlinkSync(join(tmpDir, "test.yml")); } catch { /* ignore */ } +}); + +describe("loadPerpsConfig", () => { + it("parses a valid YAML file", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadPerpsConfig({ path }); + assert.strictEqual(cfg.venue.kind, "perps"); + assert.strictEqual(cfg.network.name, "arbitrum"); + assert.strictEqual(cfg.pricing.strategy, "effective-spread"); + assert.strictEqual(cfg.sizing.strategy, "geometric-taper"); + 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") + .replace(" inventorySkewGamma: 0.5\n", " riskAversion: 0.2\n marginCallTimeSeconds: 3600\n"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); + }); + + it("rejects linear sizing on perps", () => { + const yaml = VALID_YAML + .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/); + }); + + it("expands ${VAR} tokens from env", () => { + 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 = loadPerpsConfig({ path, env }); + assert.strictEqual(cfg.network.rpcUrl, "https://example.com/rpc"); + }); + + it("supports ${VAR:-default} fallback syntax", () => { + const yaml = VALID_YAML.replace( + '"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"', + '${ABSENT_KEY:-0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890}', + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadPerpsConfig({ 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(() => loadPerpsConfig({ path }), /undeclaredWallet/); + }); + + it("throws on invalid address format", () => { + const yaml = VALID_YAML.replace( + '"0x1234567890123456789012345678901234567890"', + '"not-an-address"', + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); + }); + + it("rejects unknown top-level keys", () => { + const yaml = `${VALID_YAML}\nbogus: 1\n`; + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPerpsConfig({ path }), /Config validation failed/); + }); + + 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); + 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..febd7c7 --- /dev/null +++ b/market-maker/tests/apps/perps/main.smoke.test.ts @@ -0,0 +1,30 @@ +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", + 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"); + assert.equal(cfg.pricing.strategy, "effective-spread"); + 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 new file mode 100644 index 0000000..ad721c0 --- /dev/null +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -0,0 +1,161 @@ +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: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6 + - 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 + expirySizeDecay: 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); + 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", () => { + 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.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(" expirySizeDecay: 0.6\n", ""); + 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/collateralTracker.test.ts b/market-maker/tests/core/collateralTracker.test.ts new file mode 100644 index 0000000..22f193d --- /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, + portfolioOrderMargin: 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/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/config/units.test.ts b/market-maker/tests/core/config/units.test.ts new file mode 100644 index 0000000..550ac9e --- /dev/null +++ b/market-maker/tests/core/config/units.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +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", () => { + 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/); + }); +}); diff --git a/market-maker/tests/core/errSerializer.test.ts b/market-maker/tests/core/errSerializer.test.ts new file mode 100644 index 0000000..d2458d6 --- /dev/null +++ b/market-maker/tests/core/errSerializer.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { serializeError, toErrorInfo } from "../../src/core/errSerializer.ts"; + +describe("serializeError", () => { + 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, " at fn (test.ts:1:1)"); + }); + + 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, "first"); + }); + + 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 junk header\n at fn (file.ts:1:1)\n at g (file.ts:2:2)"; + const out = serializeError(err); + assert.equal(out.stack, " at fn (file.ts:1:1)\n at g (file.ts:2:2)"); + }); + + 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); + 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("leaves a short `data` selector untouched", () => { + const err = Object.assign(new Error("e"), { data: "0xd6bda275" }); + const out = serializeError(err); + 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: "updateOrders", + sender: "0xdef", + }); + const out = serializeError(err); + assert.equal(out.contractAddress, "0xabc"); + assert.equal(out.functionName, "updateOrders"); + 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", () => { + 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/core/gasTracker.test.ts b/market-maker/tests/core/gasTracker.test.ts new file mode 100644 index 0000000..a2431bf --- /dev/null +++ b/market-maker/tests/core/gasTracker.test.ts @@ -0,0 +1,216 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { PublicClient } from "viem"; +import { GasTracker, type GasTrackerConfig } from "../../src/core/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 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( + {} 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/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"); + }); +}); diff --git a/market-maker/tests/core/helpers.test.ts b/market-maker/tests/core/helpers.test.ts new file mode 100644 index 0000000..10dcc60 --- /dev/null +++ b/market-maker/tests/core/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/core/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/core/marketRuntime.test.ts b/market-maker/tests/core/marketRuntime.test.ts new file mode 100644 index 0000000..9cf1822 --- /dev/null +++ b/market-maker/tests/core/marketRuntime.test.ts @@ -0,0 +1,210 @@ +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}` }[]; + reduces: unknown[]; + 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: [], reduces: [], 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" }], + reduces: [], + 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: [], reduces: [], 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: [], reduces: [], 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/math.test.ts b/market-maker/tests/core/math.test.ts new file mode 100644 index 0000000..ae88711 --- /dev/null +++ b/market-maker/tests/core/math.test.ts @@ -0,0 +1,247 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + fillLossFromNotionals, + BPS_SCALE, + RollingBudget, + RollingWindow, + applyBps, + bigAbs, + calculateNotional, + notionalToSize, + QUANTITY_SCALE, + roundDownToTick, + roundToTick, + roundUpToTick, +} from "../../src/core/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("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); + }); + 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("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); + 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); + }); +}); + +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/nonceManager.test.ts b/market-maker/tests/core/nonceManager.test.ts new file mode 100644 index 0000000..516c0b3 --- /dev/null +++ b/market-maker/tests/core/nonceManager.test.ts @@ -0,0 +1,377 @@ +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 }); + // 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); + }; + + 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("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; + (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/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts new file mode 100644 index 0000000..b6b5cf0 --- /dev/null +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -0,0 +1,171 @@ +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 { + 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"], + book: { + tick: async () => 1n, + snapshot: async () => ({ bids: [], asks: [] }), + }, + ownOrders: { + list: async () => [], + subscribe: () => () => {}, + bootstrap: async () => {}, + }, + getIndexPrice: async () => prices[Math.min(i++, prices.length - 1)], + getPosition: async () => ({ netQuantity: 0n, entryPrice: 0n }), + getContext: async () => ({}), + encodeCreate: () => "0x", + encodeUpdateOrders: () => "0x", + encodeCancel: () => "0x", + executeOrders: async () => ({ receipts: [], errors: [] }), + estimateOrderMargin: () => 0n, + estimateCreateGas: async () => 0n, + }; +} + +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 per-second volatility", () => { + const tracker = new OracleTracker(makeInstrument([0n]), makeLogger()); + assert.equal(tracker.currentPrice, 0n); + assert.equal(fracVal(tracker.volatilityPerSecond), 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 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(), { + nowSec: makeClock(), + }); + for (let i = 0; i < prices.length; i++) await tracker.update(); + 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(), { + nowSec: makeClock(), + }); + await tracker.update(); + await tracker.update(); + 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(), { + nowSec: makeClock(), + }); + for (let i = 0; i < prices.length; i++) await tracker.update(); + 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/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts new file mode 100644 index 0000000..624390e --- /dev/null +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -0,0 +1,679 @@ +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, + ExecuteOrdersIntent, +} from "../../src/core/adapter.ts"; +import type { Quoter } from "../../src/core/quoter.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; +} + +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 $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; + +function makeConfig( + overrides: Partial = {}, +): OrderExecutorConfig { + return { + requoteCooldownMs: 0, + urgentRequoteThresholdTicks: 10, + staleBandAllowance: DEFAULT_ALLOWANCE, + staleSizeAllowance: DEFAULT_SIZE_ALLOWANCE, + quantityScale: PERPS_QUANTITY_SCALE, + 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: {}, + 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: [], + }; + }, + } 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, + cfg: Partial = {}, +): OrderExecutor { + return new OrderExecutor( + deps.instrument, + makeConfig(cfg), + 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)", () => { + /** + * 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 worse than the desired grid", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + // 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); + + const desired: OrderIntent[] = [ + desiredBuy(95_000_000n), + desiredSell(96_000_000n), + ]; + + await executor.reconcile(desired); + + assert.equal( + deps.cancelledOrderIds.length, + 4, + "all worse orders cancelled", + ); + assert.equal(deps.placedIntents.length, 2, "missing levels placed"); + }); + + /** + * Worse leftovers coexist with correct grid orders — cancel only the worse + * ones (outside keep zone). Better-than-grid leftovers are kept. + */ + 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); + + // 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); + + const desired: OrderIntent[] = [ + desiredBuy(95_000_000n), + desiredSell(96_000_000n), + ]; + + await executor.reconcile(desired); + + assert.equal(deps.cancelledOrderIds.length, 2, "worse leftovers cancelled"); + assert.equal( + deps.placedIntents.length, + 0, + "no new orders at already-filled prices", + ); + }); + + 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 → 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, 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. + */ + 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"); + }); +}); + +// ── quantity deficit (qty-bearing orders) ────────────────────────────────── + +describe("OrderExecutor quantity deficit", () => { + it("does not requote when resting size matches the desired grid", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + + seedOrder(deps.book, 1, "buy", 95_000_000n, 3n); + 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.equal(planned, null, "no churn when size and prices match"); + }); + + it("requotes when resting size falls below desired qty at a level", () => { + const deps = makeDeps(); + // 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); + + 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); + }); + + it("skips top-up when deficit notional is at or below the USD threshold", () => { + const deps = makeDeps(); + 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); + + 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 ───────────────────────────────── + +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 and no stale order", () => { + 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); + }); +}); + +// ── stale detection ──────────────────────────────────────────────────────── + +describe("OrderExecutor stale detection", () => { + 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); // 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); // worse than worstAsk 97 + 0.03 → cancel + + const planned = executor.plan([ + desiredBuy(95_000_000n), + desiredBuy(94_000_000n), + desiredSell(96_000_000n), + 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)), "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); // 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)), "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(); + // 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 + + const planned = executor.plan([desiredBuy(95_000_000n, 2n)]); + assert.ok(planned); + assert.equal(planned.cancels.length, 1, "trailing whole order cancelled"); + assert.equal(planned.cancels[0].orderId, makeOrderId(2)); + 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, { staleSizeAllowance: 0n }); + 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); + }); + + it("skips downsize when excess notional is at or below the USD threshold", () => { + const deps = makeDeps(); + 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); + + 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); + // $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); + 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 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, 600_000n); + assert.equal(planned.cancels.length, 0); + assert.equal(planned.reduces.length, 0); + }); + + it("futures: $50 size allowance rounds to 1 contract at ~$95", () => { + const deps = makeDeps(); + // default $50 → allowanceQty = round(50/95) = 1 contract + const executor = makeExecutor(deps, { quantityScale: 1n }); + seedOrder(deps.book, 1, "buy", 95_000_000n, 2n); // excess 1 ≤ 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 > 1 + const planned = executor.plan([desiredBuy(95_000_000n, 1n)]); + assert.ok(planned); + assert.ok(planned.cancels.length + planned.reduces.length > 0); + }); +}); + +// ── 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("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 — 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); + + 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, 1, "better leftover cancelled"); + assert.equal(planned.cancels[0].orderId, makeOrderId(2)); + 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", () => { + 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", 90_000_000n); // worse than desired buy@95 → stale + 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/perpsInstrument.test.ts b/market-maker/tests/core/perpsInstrument.test.ts new file mode 100644 index 0000000..2577647 --- /dev/null +++ b/market-maker/tests/core/perpsInstrument.test.ts @@ -0,0 +1,81 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { PerpsInstrumentAdapter } from "../../src/adapters/perps/instrument.ts"; +import type { PerpsVenueAdapter } from "../../src/adapters/perps/venue.ts"; + +const OWNER = "0x1111111111111111111111111111111111111111" as Address; +const PERPS = "0x2222222222222222222222222222222222222222" as Address; + +function makeInstrument(position: { netQuantity: bigint; netEntryValue: bigint }) { + const venue = { + address: PERPS, + wallet: { account: { address: OWNER } }, + publicClient: { + readContract: async (call: { + functionName: string; + args: readonly unknown[]; + abi: readonly unknown[]; + }) => { + assert.equal(call.functionName, "getUserPosition"); + assert.deepEqual(call.args, [OWNER]); + assert.deepEqual(call.abi, [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, + ]); + return position; + }, + }, + } as unknown as PerpsVenueAdapter; + + return new PerpsInstrumentAdapter(venue); +} + +describe("perps instrument position", () => { + it("derives a long average entry price from net entry value", async () => { + const position = await makeInstrument({ + netQuantity: 2_000_000n, + netEntryValue: 241_000_000n, + }).getPosition(); + + assert.deepEqual(position, { + netQuantity: 2_000_000n, + entryPrice: 120_500_000n, + }); + }); + + it("derives a positive average entry price for a short", async () => { + const position = await makeInstrument({ + netQuantity: -2_500_000n, + netEntryValue: -300_000_000n, + }).getPosition(); + + assert.deepEqual(position, { + netQuantity: -2_500_000n, + entryPrice: 120_000_000n, + }); + }); + + it("uses zero entry price when flat", async () => { + const position = await makeInstrument({ + netQuantity: 0n, + netEntryValue: 0n, + }).getPosition(); + + assert.deepEqual(position, { netQuantity: 0n, entryPrice: 0n }); + }); +}); diff --git a/market-maker/tests/core/portfolioCollateral.test.ts b/market-maker/tests/core/portfolioCollateral.test.ts new file mode 100644 index 0000000..a063a07 --- /dev/null +++ b/market-maker/tests/core/portfolioCollateral.test.ts @@ -0,0 +1,150 @@ +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"]; +} + +// vault, IM, MM, wallet, native, portfolio order margin +const SHARED = [100n, 200n, 300n, 400n, 500n, 600n]; + +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], + portfolioOrderMargin: r[5], + collateralToken: "0x00000000000000000000000000000000000000aa", + }; +} + +function makeBatchable(sharedValues: bigint[], pnl: bigint): BatchableCollateralAccount { + const buildMarginReadPlan = async (): Promise => ({ + shared: reads(sharedValues), + venue: reads([pnl]), + decode: (results) => ({ + ...decodeShared(results), + venueUnrealizedPnl: (results as bigint[])[6], + }), + }); + return { + buildMarginReadPlan, + snapshot: async () => { + const plan = await buildMarginReadPlan(); + return plan.decode([...sharedValues, 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, 22n); + // b's shared values are ignored (aggregator reads shared from the first plan). + const b = makeBatchable([9n, 9n, 9n, 9n, 9n, 9n], 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); + // 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 + }); + + 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, + portfolioOrderMargin: 7n, + venueUnrealizedPnl: -3n, + walletTokenBalance: 0n, + nativeBalance: 0n, + collateralToken: "0x00000000000000000000000000000000000000aa", + }), + imSpotShock: async () => 0n, + deposit: async () => {}, + canPlace: async () => true, + }; + 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.portfolioOrderMargin, 7n); // from the primary, not summed + 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).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); + 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..d90a719 --- /dev/null +++ b/market-maker/tests/core/portfolioRunner.test.ts @@ -0,0 +1,398 @@ +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}`, + })), + reduces: [], + 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), + ordersReduced: all.reduce((n, i) => n + (i.reduces?.length ?? 0), 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, + ordersReduced: 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/pricing/reservationPrice.test.ts b/market-maker/tests/core/pricing/reservationPrice.test.ts new file mode 100644 index 0000000..2923320 --- /dev/null +++ b/market-maker/tests/core/pricing/reservationPrice.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +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 +const HORIZON_SEC = 3; // matches the typical pollIntervalSec used by configs + +function makeOracle(price: bigint, vol = new Fraction(0n)): OracleTracker { + return { currentPrice: price, volatilityPerSecond: 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, + volHorizonSec: HORIZON_SEC, + }); + 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, + volHorizonSec: HORIZON_SEC, + }); + 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, + volHorizonSec: HORIZON_SEC, + }); + 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, + volHorizonSec: HORIZON_SEC, + }); + 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, + volHorizonSec: HORIZON_SEC, + }); + assert.ok(shortInv.askMid >= noInv.askMid, "short inventory should push ask mid up or equal"); + }); + + 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 = { expirationAt: futureDelivery }; + + const { bidMid, askMid } = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context, + cfg: baseCfg, + tick: TICK, + volHorizonSec: HORIZON_SEC, + nowMs, + }); + assert.ok(bidMid < askMid); + assert.ok(bidMid > 0n); + }); + + 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); + const cfg = { ...baseCfg, riskAversion: 10.0 }; + + const expired = computeReservationMidQuote({ + oracle: makeOracle(1_000_000_000n, new Fraction(1n, 100n)), + gas: makeGas(), + inventory: bigInventory, + context: { expirationAt: pastDelivery }, + cfg, + tick: TICK, + volHorizonSec: HORIZON_SEC, + 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, + volHorizonSec: HORIZON_SEC, + 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, + 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}`); + }); + + it("higher volatility produces wider spread", () => { + const opts = { + oracle: makeOracle(1_000_000_000n), + gas: makeGas(), + inventory: makeInventory(0n), + context: {}, + cfg: baseCfg, + tick: TICK, + volHorizonSec: HORIZON_SEC, + }; + + 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/core/realizedVolatility.test.ts b/market-maker/tests/core/realizedVolatility.test.ts new file mode 100644 index 0000000..c66a7fe --- /dev/null +++ b/market-maker/tests/core/realizedVolatility.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { realizedVolatility } from "../../src/core/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/core/riskManager.test.ts b/market-maker/tests/core/riskManager.test.ts new file mode 100644 index 0000000..7870c1c --- /dev/null +++ b/market-maker/tests/core/riskManager.test.ts @@ -0,0 +1,295 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +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 { + 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, + inventorySkew: new Fraction(0n), + ...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(), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + assert.equal(r.check(), true); + assert.equal(r.halted, false); + }); + + it("halts when collateral drops below minimum", () => { + 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 collateral = makeCollateral({ vaultBalance: 400_000_000n }); + const r = new RiskManager( + makeConfig({ maxDailyLossUsd: 500_000_000n, minCollateralBalance: 0n }), + makeInventory(), + collateral, + 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(), + makeCollateral(), + 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 }), + 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 }), + makeCollateral({ 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 }), + makeCollateral({ 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 }), + makeCollateral({ utilizationPct: 90 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + 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 }), + makeInventory(), + makeCollateral(), + 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(), + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); + r.initialize(); + r.recordGasCost(15_000_000n); + r.check(); + assert.equal(r.throttled, true); + assert.equal(r.throttleReason, "gas_daily"); + }); + + it("resets PnL counters on day rollover", () => { + const r = new RiskManager( + makeConfig({ minCollateralBalance: 0n, maxDailyLossUsd: 1_000_000_000n }), + makeInventory(), + makeCollateral(), + 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); + }); + + 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/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); + }); +}); diff --git a/market-maker/tests/core/sizing/geometricTaper.test.ts b/market-maker/tests/core/sizing/geometricTaper.test.ts new file mode 100644 index 0000000..6ab6607 --- /dev/null +++ b/market-maker/tests/core/sizing/geometricTaper.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { geometricTaperSizes } from "../../../src/core/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/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"); + }); +}); diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts new file mode 100644 index 0000000..f295c83 --- /dev/null +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -0,0 +1,214 @@ +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, ReduceIntent, 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.sendCall) 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 calls: `0x${string}`[] = []; + const venue = { + kind, + sendCall: async (data: `0x${string}`) => { + if (opts.fail) throw new Error(`${kind} boom`); + calls.push(data); + return "0xhash" as const; + }, + multicall: async () => { + throw new Error("multicall should not be used"); + }, + } as unknown as VenueAdapter; + return { venue, calls }; +} + +function makeMarket( + venue: VenueAdapter, + id: string, + cancels: string[], + creates: { price: bigint; im: bigint; size?: bigint }[], + expirationAt?: bigint, + reduces: ReduceIntent[] = [], +): 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}` }[], + 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 + ? `${o.price}@${o.expirationAt}` + : o.price.toString(), + ) + .join(","); + return `0xU${cancelPart}|${reducePart}>${createPart}` 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}` })), + reduces, + creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: c.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 }], 100n), + makeMarket(venue, "f2", [], [{ price: 2n, im: 400n }], 200n), + ]; + 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, calls } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xdead"], [{ price: 1n, im: 300n }], 100n)]; + 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(calls, ["0xUdead|>"]); // updateOrders with cancels only + }); + + it("merges all same-venue expiries into one updateOrders call", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, calls } = makeVenue("futures"); + const markets = [ + 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); + 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 () => { + 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.calls.length, 1); + }); + + it("keeps a large cancel+create batch in one updateOrders (no weight splitting)", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, calls } = makeVenue("futures"); + const markets = [ + makeMarket( + venue, + "f1", + ["0xa", "0xb", "0xc"], + [{ price: 1n, im: 0n, size: 50n }], + 100n, + ), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 1); + assert.deepEqual(calls, ["0xUa+b+c|>1@100"]); + }); + + it("dry run submits nothing but reports intended counts", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + 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(calls.length, 0); + assert.equal(res.ordersCancelled, 1); + assert.equal(res.ordersPlaced, 1); + }); +}); 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"] +} 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 diff --git a/points-indexer/.env.example b/points-indexer/.env.example new file mode 100644 index 0000000..2ed9ece --- /dev/null +++ b/points-indexer/.env.example @@ -0,0 +1,14 @@ +# ── Subgraph manifest ────────────────────────────────────────────────────── +# 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 + +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..11c6847 --- /dev/null +++ b/points-indexer/.gitignore @@ -0,0 +1,19 @@ +build +data +generated +subgraph.yaml +.env* +!.env.example +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 new file mode 100644 index 0000000..649c487 --- /dev/null +++ b/points-indexer/README.md @@ -0,0 +1,75 @@ +# 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 + +Two data sources, from the points contracts in `../contracts`: + +| Source | Events | Purpose | +| --- | --- | --- | +| `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 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 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 + +```graphql +{ + userPoints(first: 100, orderBy: total, orderDirection: desc) { + address + total + totalEarned + mintCount + redeemedPoints + } + pointsProgram(id: "0") { + totalPoints + totalUsers + mintCount + finalized + } +} +``` + +## Preconditions + +- **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. + +## 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 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..eca7d49 --- /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, 0n, 0n], { + account: venue.account.address, chain: null, + }); + + 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, 0n, 0n], { + 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.address, chain: null, + }); + + 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.address, chain: null }); + + 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..b6edbbb --- /dev/null +++ b/points-indexer/integration/points-redemption.test.ts @@ -0,0 +1,107 @@ +/** + * 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.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 { 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, 0n, 0n], + { + 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.address, chain: null, + }, + ); + + // Wind down: finalize, fund the pool, open redemption, then alice swaps. + 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 + 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..27525ca --- /dev/null +++ b/points-indexer/integration/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../contracts/tsconfig.json", + "compilerOptions": { + "types": [ + "node" + ], + "paths": { + "viem": [ + "./node_modules/viem" + ], + "viem/*": [ + "./node_modules/viem/*" + ] + } + }, + "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 new file mode 100644 index 0000000..88dce6b --- /dev/null +++ b/points-indexer/package.json @@ -0,0 +1,49 @@ +{ + "name": "points-indexer", + "license": "UNLICENSED", + "engines": { + "node": ">=22.6.0" + }, + "type": "module", + "scripts": { + "clean": "rm -rf data generated build 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", + "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 -v 0.6.0", + "test:integration": "hardhat test nodejs", + "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", + "lint": "biome lint .", + "graph:api": "open http://localhost:8030/graphql/playground", + "lint:fix": "biome check --write .", + "typecheck": "tsgo --noEmit -p integration/tsconfig.json" + }, + "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.11", + "@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", + "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#v0.4.2&path:packages/matchstick-ts", + "typescript": "^5.9.3", + "viem": "2.52.2" + }, + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" +} diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml new file mode 100644 index 0000000..9d0dcaa --- /dev/null +++ b/points-indexer/pnpm-lock.yaml @@ -0,0 +1,4753 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@graphprotocol/graph-ts': + 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.11 + version: 3.0.11(hardhat@3.9.1) + '@nomicfoundation/hardhat-node-test-runner': + 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.52.2(typescript@5.9.3)(zod@3.25.76)) + '@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 + collateral-margin-contracts: + specifier: link:../contracts + version: link:../contracts + hardhat: + specifier: ^3.9.1 + version: 3.9.1 + hardhat-matchstick-ts: + 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#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 + viem: + specifier: 2.52.2 + 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'} + + '@babel/helper-validator-identifier@7.29.7': + 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==} + + '@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'} + + '@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==} + + '@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'} + + '@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==} + + '@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/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'} + + '@noble/hashes@1.4.0': + 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'} + + '@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'} + + '@nomicfoundation/edr-darwin-arm64@0.12.1': + resolution: {integrity: sha512-KRB7oRupR2CqGHTACDhdS/EJGLN2rft1+5UNeimbXYe9nS3usUNGjNJyIjvoxzFthqnFM3+vaDQwyIZfq/eRjw==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-darwin-x64@0.12.1': + resolution: {integrity: sha512-h6J3otsX5ib1md5V/M281ZS37FC6mAH8QlxVi3YMe9wOpEOpBRkqfhQAeFCekdx+5pqNHO/STi5OyKwCd4YAfw==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-arm64-gnu@0.12.1': + resolution: {integrity: sha512-yqJcBgusn+MQFCemVrm7VIYjqQLaFouo0DBAbApE0GHQ7MnVFmbW2d2WCEln3jZOZgY0FH0tfnQw/NfK2xo2zg==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-linux-arm64-musl@0.12.1': + resolution: {integrity: sha512-JPkUOazqotQMvU2wsOQymxusCKyWaWdqHyxQKqwrqz81O+jOEXvMHUp20a6cRbVGOoGHx334ORj+daSGKvt5og==} + engines: {node: '>= 20'} + + '@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.1': + resolution: {integrity: sha512-Rw7hhyk8PdZy3bBYVJrQX1M1AIBhy4vFnWPfbdY50c+ZfX/c82PYVg+B92+XaC5avMon/KiIhfB2fNLcyJy4uw==} + engines: {node: '>= 20'} + + '@nomicfoundation/edr-win32-x64-msvc@0.12.1': + resolution: {integrity: sha512-z2ILUf8P/oqG8t2tkPCpmhzSpo+LMZylLFUPGMgugHwe8OX1GyU11g0bQU8SoIwHozy7MLzTMX0NZ/14LWSN7Q==} + engines: {node: '>= 20'} + + '@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-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 + + '@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-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-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'} + + '@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/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==} + + '@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==} + + '@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'} + + '@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 + + 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'} + + 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'} + + 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'} + + 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'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + 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'} + + 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'} + + 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'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=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==} + + 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'} + + 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.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + 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-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==} + + 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==} + + 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==} + + 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} + + 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 + '@nomicfoundation/hardhat-viem': ^3 + hardhat: ^3 + + hardhat@3.9.1: + resolution: {integrity: sha512-yg+0oH5tWqdsxITh6fAJjAWOSHOkC2VPlsJDJwoifs2QS1t7kyRciMy5O2F846qzH+4iqRn1rbv/5voykX3RSQ==} + hasBin: true + + 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: '*' + + 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==} + + 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 + + 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==} + + 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-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==} + + 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==} + + 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: + '@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'} + + 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'} + + 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'} + + 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'} + + 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'} + + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + + 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'} + + 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 + + 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==} + + 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-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==} + + 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'} + + 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'} + + reusify@1.1.0: + 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 + 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@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'} + + 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==} + + 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'} + + 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@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'} + + 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 + + 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 + + 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 + + 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'} + + 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: + + '@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 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@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': + 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 + + '@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': + 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': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@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/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': + 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.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/edr@0.12.1': + dependencies: + '@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-errors@3.0.17': + dependencies: + '@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.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.1 + 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-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.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.1 + 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 + 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/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 + + '@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 + + '@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 + 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 + + 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: + 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 + + ansi-styles@5.2.0: {} + + 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 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.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: {} + + diff-sequences@29.6.3: {} + + 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 + + env-paths@2.2.1: {} + + 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 + + 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: {} + + 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.1: {} + + 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-equals@5.4.0: {} + + 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: {} + + fsevents@2.3.3: + optional: true + + 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: {} + + 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)) + hardhat: 3.9.1 + matchstick-ts: link:../matchstick-ts + + hardhat@3.9.1: + dependencies: + '@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 + 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: {} + + 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 + + isows@1.0.7(ws@8.20.1): + dependencies: + ws: 8.20.1 + + 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 + + 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: + dependencies: + argparse: 2.0.1 + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-stream-stringify@3.1.6: {} + + 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 + + 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 + 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: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + 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 + 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 + + 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: {} + + p-fifo@1.0.0: + dependencies: + 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 + 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: {} + + 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: {} + + 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-is@18.3.1: {} + + 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: {} + + resolve.exports@2.0.3: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + 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@7.2.0: + dependencies: + has-flag: 4.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: {} + + 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: + 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@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@6.26.0: {} + + 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: {} + + 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: + 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: {} + + ws@8.20.1: {} + + ws@8.21.0: {} + + 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/pnpm-workspace.yaml b/points-indexer/pnpm-workspace.yaml new file mode 100644 index 0000000..9c2036b --- /dev/null +++ b/points-indexer/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# pnpm 11 keys allowBuilds by exact resolution, so the matchstick entries must be +# updated whenever the git specs move to a different commit. +allowBuilds: + esbuild: false + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/hardhat-matchstick-ts: true + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/matchstick-ts: true diff --git a/points-indexer/schema.graphql b/points-indexer/schema.graphql new file mode 100644 index 0000000..343036b --- /dev/null +++ b/points-indexer/schema.graphql @@ -0,0 +1,86 @@ +# 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` / `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) { + "Singleton entity, id is always the literal string \"0\"." + id: String! + + # Contract identity + pointsToken: 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 + + # Redemption (from PointsRedeemer events) + totalRedeemedPoints: BigInt! + totalGovDistributed: BigInt! + + # Counters + totalUsers: Int! + mintCount: Int! # number of mint events across all users + 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! + + # Redemption + redeemedPoints: BigInt! + govReceived: BigInt! + + # Counters + mintCount: Int! # number of mints credited to this account + + # Relations + mints: [PointsMint!]! @derivedFrom(field: "user") + redemptions: [PointsRedemption!]! @derivedFrom(field: "user") + + firstSeenAt: BigInt! + lastActivityAt: BigInt! +} + +# ── Individual mint (HP Transfer with from == 0x0) ────────────────────────── +type PointsMint @entity(immutable: true) { + id: Bytes! # tx hash + log index + user: UserPoints! + amount: BigInt! + + 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/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/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..01a81a9 --- /dev/null +++ b/points-indexer/src/points.ts @@ -0,0 +1,67 @@ +import { Address, Bytes, dataSource } from "@graphprotocol/graph-ts"; +import { Finalized, Transfer } from "../generated/Points/Points"; +import { createEventId } from "./ids"; +import { PointsMint } from "../generated/schema"; +import { getOrCreateProgram, getOrCreateUser } from "./helpers"; + +const ZERO_ADDRESS = Address.zero(); + +// ── Helpers ─────────────────────────────────────────────────────────────── + +// ── 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`, 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; + 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); + 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); + 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(); +} 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(); +} diff --git a/points-indexer/subgraph.template.yaml b/points-indexer/subgraph.template.yaml new file mode 100644 index 0000000..9769af6 --- /dev/null +++ b/points-indexer/subgraph.template.yaml @@ -0,0 +1,62 @@ +# 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 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 + mints 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 + - PointsMint + 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 + + # ── 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..30d1e0e --- /dev/null +++ b/points-indexer/tests/helpers.ts @@ -0,0 +1,44 @@ +/** + * 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 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 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..fbefdf2 --- /dev/null +++ b/points-indexer/tests/points.test.ts @@ -0,0 +1,156 @@ +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, handleTransfer } from "../src/points"; +import { + POINTS_ADDRESS, + // REDEEMER_ADDRESS, + mockDataSource, + paramAddr, + 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 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, counts the mint, and records it", () => { + const alice = userAddress(1); + 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, 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 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", () => { + handleFinalized(newTypedMockEventWithParams([])); + assert.fieldEquals("PointsProgram", "0", "finalized", "true"); + }); +}); + +// 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"); +// }); +// }); 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 {} 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..ceeb2c1 --- /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.22.0" +} 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..15016a2 --- /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 `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)` + * 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` + `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 + * 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 new file mode 100644 index 0000000..cbfeda2 --- /dev/null +++ b/portfolio-margin/tests/mm.test.ts @@ -0,0 +1,696 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + fillLoss, + futuresUnrealizedPnl, + imRequired, + imSurplus, + mmRequired, + mmSurplus, + netDeltaWad, + perpUnrealizedPnl, + stressLoss, + unrealizedLoss, + venueFillLoss, + worstLegStressLoss, +} from "../src/mm.ts"; +import type { + AccountSnapshot, + Address, + MMParams, + RestingOrders, +} from "../src/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; + +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 { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, + ...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, + 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, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + assert.equal(netDeltaWad(snap, PARAMS), -2_000_000_000_000_000_000n); + }); + + it("adds futures buyer delta (±1 per contract, no duration factor)", () => { + // Buyer of 1 contract → +1 * 1e18 WAD delta. + const snap = emptySnapshot({ + futures: { + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); + }); + + it("subtracts futures seller delta", () => { + const snap = emptySnapshot({ + futures: { + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: -1n, + netEntryValue: -50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + assert.equal(netDeltaWad(snap, PARAMS), -1n * 10n ** 18n); + }); + + it("sums perps + futures legs into one signed delta", () => { + const snap = emptySnapshot({ + perp: { + netQty: 1_000_000n, + entryPrice: 100n, + orders: NO_ORDERS, + fundingOwed: 0n, + }, // +1e18 + futures: { + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + { + expirationAt: 1_756_416_000n, + netQuantity: -1n, + netEntryValue: -60n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + // Perp +1e18; futures +1e18 - 1e18 = 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: perpUnrealizedPnl", () => { + it("returns 0 for a flat user", () => { + assert.equal(perpUnrealizedPnl(emptySnapshot(), PARAMS, 100_000_000n), 0n); + }); + + it("is positive for a profitable long (P > entry)", () => { + 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); + }); + + 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, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + 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("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({ + futures: { + positions: [ + { expirationAt: EXPIRY_A, netQuantity: 2n, netEntryValue: 200n, settlementPrice: 60n }, + ], + orders: NO_ORDERS, + }, + }); + + 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: futuresUnrealizedPnl", () => { + it("returns 0 with no positions", () => { + 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: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + // 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: EXPIRY_A, + netQuantity: -1n, + netEntryValue: -50n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + assert.equal(futuresUnrealizedPnl(snap, 60n), -10n); + }); + + 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: EXPIRY_A, + netQuantity: 1n, + netEntryValue: 50n, + settlementPrice: 0n, + }, // P=60 → +10 + { + expirationAt: EXPIRY_B, + netQuantity: -1n, + netEntryValue: -30n, + settlementPrice: 0n, + }, // P=60 → -30 + ], + orders: NO_ORDERS, + }, + }); + 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 with no orders, all four return only owed funding", () => { + const snap = emptySnapshot({ + balance: 1_000n, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 50n }, + }); + // 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, + 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, + 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}`, + ); + }); +}); diff --git a/portfolio-margin/tests/solve.test.ts b/portfolio-margin/tests/solve.test.ts new file mode 100644 index 0000000..f69b3a6 --- /dev/null +++ b/portfolio-margin/tests/solve.test.ts @@ -0,0 +1,683 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +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% + tokenDecimals: 6, + perpQuantityDecimals: 6, +}; + +function emptySnapshot( + overrides: Partial = {}, +): AccountSnapshot { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, + ...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`, + ); + } +} + +/** + * 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, + 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); + 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, + orders: NO_ORDERS, + 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, + orders: NO_ORDERS, + 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, + orders: NO_ORDERS, + 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 @ $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: 30_000_000n, + futures: { + positions: [ + { + expirationAt: 1_756_416_000n, + netQuantity: 1n, + netEntryValue: 50_000_000n, + settlementPrice: 0n, + }, + ], + orders: NO_ORDERS, + }, + }); + const out = solveLiquidationThresholds(snap, PARAMS, 50_000_000n); + assert.notEqual(out.liqDown, undefined); + if (out.liqDown !== undefined) { + assertCrossing(snap, PARAMS, out.liqDown, "down"); + } + }); + + 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, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + const withDrag = emptySnapshot({ + balance: 20_000_000n, + perp: { + netQty: 1n * QTY_SCALE, + entryPrice: 100_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; + 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, + orders: NO_ORDERS, + 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, + 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, + ); + 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, + orders: NO_ORDERS, + 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("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, + orders: NO_ORDERS, + 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/portfolio-margin/tests/solveTarget.test.ts b/portfolio-margin/tests/solveTarget.test.ts new file mode 100644 index 0000000..8667eb5 --- /dev/null +++ b/portfolio-margin/tests/solveTarget.test.ts @@ -0,0 +1,419 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + simulateFuturesClose, + simulatePerpClose, + solveFuturesClosesToTarget, + solvePerpCloseToTarget, +} 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, + tokenDecimals: 6, + perpQuantityDecimals: 6, +}; + +const FEE = 1_000_000n; // $1 flat liquidation fee + +const EXPIRY_A = 1_756_416_000n; +const EXPIRY_B = 1_759_008_000n; + +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, + settlementPrice = 0n, +) { + return { + expirationAt, + netQuantity, + netEntryValue: entry * netQuantity, + settlementPrice, + }; +} + +function futuresSnapshot( + overrides: Partial = {}, +): AccountSnapshot { + return { + user: USER, + balance: 0n, + perp: { netQty: 0n, entryPrice: 0n, orders: NO_ORDERS, fundingOwed: 0n }, + futures: { positions: [], orders: NO_ORDERS }, + ...overrides, + }; +} + +/** 12-contract long aggregate — same economics as the old twelve-lot fixture. */ +function twelveLong(): AccountSnapshot { + return futuresSnapshot({ + balance: BALANCE, + futures: { positions: [futuresAgg(12n, ENTRY)], orders: NO_ORDERS }, + }); +} + +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: [futuresAgg(1n, ENTRY)], orders: NO_ORDERS }, + }); + const closes = solveFuturesClosesToTarget(snap, PARAMS, P_MODERATE, FEE); + assert.equal(closes.length, 0); + }); + + 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 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)"); + + 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"); + }); + + it("is the DEEPEST in-band close — one more contract breaches IM", () => { + const snap = twelveLong(); + const P = P_MODERATE; + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + const qty = totalCloseQty(closes); + if (qty < 11n) { + const oneMore: FuturesCloseLeg[] = [ + { expirationAt: EXPIRY_A, closeQty: qty + 1n }, + ]; + const after = simulateFuturesClose(snap, oneMore, P, FEE); + assert.ok( + imSurplus(after, PARAMS, P) > 0n, + "closing one more contract should overshoot IM", + ); + } + }); + + 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 = twelveLong(); + const P = P_MODERATE; + const degenerate: MMParams = { ...PARAMS, imSpotShock: PARAMS.mmSpotShock }; + 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", () => { + const snap = futuresSnapshot({ + balance: BALANCE, + futures: { + positions: [ + futuresAgg(6n, ENTRY, EXPIRY_A), + futuresAgg(6n, ENTRY, EXPIRY_B), + ], + orders: NO_ORDERS, + }, + }); + const P = P_MODERATE; + assert.ok(mmSurplus(snap, PARAMS, P) < 0n); + + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + assert.ok(totalCloseQty(closes) > 1n); + + 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, + `closures must be balanced within one contract (A=${countA}, B=${countB})`, + ); + + const after = simulateFuturesClose(snap, closes, P, FEE); + assert.ok(mmSurplus(after, PARAMS, P) >= 0n); + 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, + futures: { + positions: [ + futuresAgg(8n, ENTRY, EXPIRY_A), + futuresAgg(4n, ENTRY, EXPIRY_B), + ], + orders: NO_ORDERS, + }, + }); + const P = P_MODERATE; + const closes = solveFuturesClosesToTarget(snap, PARAMS, P, FEE); + 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}`, + ); + } + }); +}); + +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, + orders: NO_ORDERS, + fundingOwed: 0n, + }, + }); + assert.equal(solvePerpCloseToTarget(snap, PARAMS, P_MODERATE, FEE), 0n); + }); + + it("simulatePerpClose reduces qty toward zero", () => { + const snap = futuresSnapshot({ + balance: BALANCE, + 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"] +}